DeSmell — Smell Detection Catalog

Complete reference guide for all 14 presentation-layer code smells detected by DeSmell in Jetpack Compose applications. Each smell includes detection rules, metrics, thresholds, and practical examples to help developers write cleaner, more maintainable Compose code.

Overview

Repository-ready Detector-aligned Metric-based

DeSmell is a static analysis tool that detects code smells in Jetpack Compose presentation layers. It analyzes Kotlin code using AST/UAST analysis to identify 14 distinct types of code smells that violate declarative UI principles, reduce maintainability, or cause performance issues.

This catalog provides detailed specifications for each smell, including:

  • What it detects: The specific patterns and code structures that trigger the smell
  • Why it matters: The impact on code quality, performance, or maintainability
  • How it's measured: The metrics and formulas used for detection
  • When it triggers: The thresholds that cause a report
  • How to fix it: Examples of problematic code and recommended solutions
Note: This catalog is aligned with the detector implementation (rules.pdf). All metrics, formulas, and thresholds match the actual behavior of the DeSmell tool.

Quick Reference Table

Smell Detector ID Primary Metric Threshold Category
CFCComposableFunctionComplexityIssueWeighted complexityCFC ≥ 25 or loops > 4Architecture
Constants in ComposableConstantsInComposableOccurrenceAny occurrenceRecomposition
SEDHighSideEffectDensityIssuesideEffectCount/uiNodeCountSED ≥ 0.30 and sideEffectCount ≥ 2Side-Effects
LIULogicInUIIssueControl-flow densityLIU > 0.30Architecture
Multiple FlowsMultipleFlowCollectionsPerComposableFlow count> 2Architecture
MutableState in ConditionsMutableStateInConditionOccurrenceAny occurrenceRecomposition
State MutationMutableStateMutationInComposableOccurrenceAny occurrenceState
Non-Savable TypeNonSavableRememberSaveableOccurrenceAny occurrenceState
rememberUpdatedStateRememberUpdatedStateWithConstantOccurrenceAny occurrenceRecomposition
Reused KeyReusedKeyInNestedScopeBinaryAny nested reuseRecomposition
SECSideEffectComplexityIssueEffect complexitySEC ≥ 10Side-Effects
Slot CountSlotCountInComposableSlot count> 3API Design
Reactive State Pass-ThroughReactiveStatePassThroughPass-through chain lengthchain ≥ 2Recomposition
Non-Snapshot-Aware Collections in StateNonSnapshotAwareCollectionInStateOccurrenceIn-place mutation on state-held collectionRecomposition

Smell Catalog

1. Composable Function Complexity (CFC)

ComposableFunctionComplexityIssue Architectural Responsibility

What it detects: Composables that have accumulated too much complexity, mixing UI rendering with business logic, side-effect orchestration, and data access. These composables act more like controllers than pure UI components.

Why it matters

High CFC indicates that a composable is doing too much. This makes the code harder to test, maintain, and reason about. It also increases the risk of unnecessary recompositions and makes it difficult to follow Compose's declarative principles.

What's measured

Metric Description
Branches Count of if, else-if, and when entries (excluding else)
Loops Imperative loops (for, while, do-while) and functional loops (forEach, map, filter, etc.)
maxDepth Maximum nesting depth of control structures
SEC_total Sum of Side-Effect Complexity scores from all effect blocks in the composable
SED Number of side-effect blocks (LaunchedEffect, DisposableEffect, etc.)
parameters Count of function parameters (excluding design-only params)
viewModelAccesses Number of direct ViewModel property or method accesses

Formula

CFC = 2×branches + 3×loops + 2×maxDepth + 1×SEC_total + 2×SED + 1×parameters + 3×viewModelAccesses

Thresholds

Primary threshold: CFC ≥ 25 (configurable via lint options)
Hard rule: loops > 4 → always reported regardless of total CFC score

How to fix

  • Extract complex logic into separate functions or ViewModel methods
  • Break large composables into smaller, focused components
  • Move side-effect orchestration to ViewModel or use cases
  • Reduce ViewModel accesses by passing only needed state as parameters
  • Simplify control flow by using derived state or state machines

2. Constants in Composable

ConstantsInComposable Recomposition Efficiency

What it detects: Immutable constant values declared directly inside a composable function body without being wrapped in remember.

Why it matters

Constants declared inside composables are recreated on every recomposition, causing unnecessary allocations. This wastes memory and CPU cycles. Constants should be hoisted outside the composable or wrapped in remember to avoid redundant reallocation.

What counts as a constant

  • Literals: 42, "Hello", true
  • Constant expressions: 2 + 3, 10 * 5
  • Extension functions: 16.dp, 14.sp
  • Immutable factory calls: listOf(1, 2, 3), mapOf("key" to "value")
  • Constructor calls with constant arguments
  • String templates without interpolation: "Hello ${"World"}"

Examples

❌ Bad
@Composable fun MyScreen() { val padding = 16.dp // ❌ Recreated on every recomposition val colors = listOf(Color.Red, Color.Blue) // ❌ New list allocated each time Column(modifier = Modifier.padding(padding)) { // ... } }
✅ Good
// Option 1: Hoist to top level private val SCREEN_PADDING = 16.dp private val SCREEN_COLORS = listOf(Color.Red, Color.Blue) @Composable fun MyScreen() { Column(modifier = Modifier.padding(SCREEN_PADDING)) { // ... } } // Option 2: Use remember @Composable fun MyScreen() { val padding = remember { 16.dp } val colors = remember { listOf(Color.Red, Color.Blue) } Column(modifier = Modifier.padding(padding)) { // ... } }

Threshold

Zero tolerance: Any constant declaration inside a composable (not in remember) is reported.
Note: var declarations are excluded, as they're expected to change. Constants inside remember { } blocks are also excluded.

3. High Side-Effect Density (SED)

HighSideEffectDensityIssue Side-Effect Orchestration

What it detects: Composables where side-effects (lifecycle management, reactive coordination) dominate the UI rendering code, suggesting misplaced coordination logic.

Why it matters

When a composable has too many side-effects relative to its UI code, it's likely performing coordination logic that belongs in a ViewModel or controller. This makes the composable harder to test and violates separation of concerns.

What's counted

Side-effects:

  • LaunchedEffect, DisposableEffect, SideEffect
  • produceState, produceRetainedState
  • Restartable effects and effect-like constructs

UI nodes: The number of composable UI calls (render-layer composable invocations like Text, Button, Column, etc.), not raw statement count.

Formula

SED = sideEffectCount / uiNodeCount

Thresholds

Both conditions must be met:
  • sideEffectCount ≥ 2
  • SED ≥ 0.30 (30% of the composable is side-effects)

Additional diagnostics

The detector also reports when:

  • Duplicate LaunchedEffect keys are detected (suggesting consolidation)
  • Multiple effects share dependencies and could be merged

How to fix

  • Move coordination logic to ViewModel
  • Consolidate multiple effects with shared dependencies
  • Extract side-effect logic into custom composables or hooks
  • Use state hoisting to reduce the need for multiple effects

4. Logic in UI Density (LIU)

LogicInUIIssue Architectural Responsibility

What it detects: Composables where imperative control flow (if/when/loops) dominates the code, indicating business logic leakage into the UI layer.

Why it matters

Composables should map state to UI declaratively. When they contain too much imperative decision-making, it suggests business logic has leaked into the presentation layer, making the code harder to test and maintain.

What's counted

Control flow constructs:

  • if, else-if
  • when entries (excluding else)
  • Loops: for, while, do-while, and functional loops
  • break, continue, throw
  • try / catch / finally

Weighting

Control flow is weighted differently based on context:

  • Render control flow (directly shaping UI structure): weight 1.0
  • Behavioral control flow (inside callbacks/modifiers): weight 0.5

Formula

LIU = (CF_render + 0.5×CF_behavior) / totalStatements

Guards

The detector skips analysis if:

  • totalStatements < 5 (too small to be meaningful)
  • controlFlowCount < 2 (not enough control flow)
  • controlFlowCount > 6 (already obviously a controller - avoid noise)

Threshold

Report when: LIU > 0.30 (30% of statements are control flow) with guards above.

How to fix

  • Move business logic to ViewModel or use cases
  • Use derived state to precompute decisions
  • Extract complex conditional rendering into separate composables
  • Use state machines or sealed classes to model UI state

5. Multiple Flow Collections per Composable

MultipleFlowCollectionsPerComposable Architectural Responsibility

What it detects: Composables that collect too many independent reactive streams (Flows), increasing recomposition frequency and coupling to state management.

Why it matters

Each Flow collection creates a separate recomposition trigger. Multiple independent flows mean the composable can recompose frequently and unpredictably. This also suggests missing state aggregation at the ViewModel layer.

What counts

Distinct invocations of:

  • collectAsState()
  • collectAsStateWithLifecycle()

Threshold

Maximum allowed: 2 flow collections per composable
Report when: flowCollectionCount > 2

Examples

❌ Bad
@Composable fun MyScreen(viewModel: MyViewModel) { val uiState = viewModel.uiState.collectAsStateWithLifecycle() val loadingState = viewModel.loadingState.collectAsStateWithLifecycle() val errorState = viewModel.errorState.collectAsStateWithLifecycle() // ❌ 3 flows // ... }
✅ Good
// Aggregate state in ViewModel data class MyUiState( val content: String, val isLoading: Boolean, val error: String? ) @Composable fun MyScreen(viewModel: MyViewModel) { val uiState = viewModel.uiState.collectAsStateWithLifecycle() // ✅ Single aggregated state // ... }

How to fix

  • Aggregate multiple flows into a single state class in the ViewModel
  • Use combine or flowOf to merge related flows
  • Extract some flow collections into child composables
  • Consider using a state holder pattern to manage related state

6. MutableState in Boolean Conditions

MutableStateInCondition Recomposition Efficiency

What it detects: Reading MutableState.value directly inside boolean or comparison expressions (if conditions, when subjects, comparisons).

Why it matters

Reading mutable state inside conditions causes unnecessary recompositions. The condition is re-evaluated on every recomposition, even when the state hasn't changed in a way that affects the condition's result. Using derived state makes the recomposition behavior more predictable and efficient.

Flagged examples

❌ Bad
@Composable fun MyScreen() { val count = remember { mutableStateOf(0) } if (count.value > 3) { // ❌ Reading .value in condition Text("High count") } when (count.value) { // ❌ Reading .value in when 0 -> Text("Zero") else -> Text("Non-zero") } }

Allowed patterns

  • if (myBooleanState) when the type is MutableState<Boolean> (direct boolean state)
  • when over enums (stable values)
  • Use of derivedStateOf to precompute the condition
✅ Good
@Composable fun MyScreen() { val count = remember { mutableStateOf(0) } val isHighCount = remember { derivedStateOf { count.value > 3 } } // ✅ Derived state if (isHighCount.value) { Text("High count") } // Or use the state directly if it's boolean val isEnabled = remember { mutableStateOf(true) } if (isEnabled.value) { // ✅ OK for boolean state // ... } }

Threshold

Per occurrence: Any detected usage is reported immediately.

7. MutableState Mutation in Composable

MutableStateMutationInComposable State Management

What it detects: Direct mutation of state during composition (not inside callbacks or effects).

Why it matters

Composables should be idempotent - calling them with the same inputs should produce the same result. Mutating state during composition violates this principle and can cause feedback loops, infinite recompositions, and unpredictable behavior.

Flagged patterns

❌ Bad
@Composable fun MyScreen() { val count = remember { mutableStateOf(0) } count.value = 5 // ❌ Mutation during composition var localState by remember { mutableStateOf(0) } localState = 10 // ❌ Mutation during composition Text("Count: $count") }

Allowed patterns

Mutations are allowed (and expected) in:

  • Callbacks: onClick, onTextChanged, etc.
  • Side-effect blocks: LaunchedEffect, DisposableEffect, etc.
✅ Good
@Composable fun MyScreen() { val count = remember { mutableStateOf(0) } Button(onClick = { count.value++ }) { // ✅ Mutation in callback Text("Increment") } LaunchedEffect(Unit) { count.value = 10 // ✅ Mutation in effect } Text("Count: ${count.value}") }

Threshold

Per occurrence: Any mutation outside callbacks or effects is reported.

How to fix

  • Move state mutations to event handlers (onClick, etc.)
  • If initialization is needed, use LaunchedEffect or DisposableEffect
  • Use state hoisting to manage state in a parent composable or ViewModel

8. Non-Savable Type in rememberSaveable

NonSavableRememberSaveable State Restoration

What it detects: Using rememberSaveable with types that cannot be persisted through Android's Bundle/state restoration mechanism without a custom Saver.

Why it matters

If a non-savable type is used with rememberSaveable without a custom saver, state restoration will silently fail. The state will be lost when the activity is recreated (e.g., on configuration change or process death).

Savable types (no custom saver needed)

  • Primitives: Int, Boolean, String, Float, etc.
  • String
  • Enums
  • Parcelable types
  • Serializable types
  • Types annotated with @Serializable (Kotlinx Serialization)

Examples

❌ Bad
data class User(val name: String, val age: Int) // Not Parcelable or Serializable @Composable fun MyScreen() { val user = rememberSaveable { User("John", 30) } // ❌ Non-savable type // State will be lost on configuration change! }
✅ Good
// Option 1: Make it Parcelable @Parcelize data class User(val name: String, val age: Int) : Parcelable @Composable fun MyScreen() { val user = rememberSaveable { User("John", 30) } // ✅ Now savable } // Option 2: Use a custom Saver @Composable fun MyScreen() { val user = rememberSaveable( saver = Saver( save = { "${it.name}|${it.age}" }, restore = { it.split("|").let { parts -> User(parts[0], parts[1].toInt()) } } ) ) { User("John", 30) } // ✅ Custom saver provided }

Threshold

Per occurrence: Any non-savable usage without a custom saver is reported.

9. rememberUpdatedState with Constant

RememberUpdatedStateWithConstant Recomposition Efficiency

What it detects: Using rememberUpdatedState to wrap a constant or immutable value that never changes.

Why it matters

rememberUpdatedState is designed for values that change across recompositions (like callbacks). Using it with constants misrepresents mutability, introduces unnecessary snapshot reads, and obscures the code's intent.

What counts as constant

  • Literals: 42, "Hello"
  • Immutable factory calls: listOf(1, 2, 3)
  • const val declarations
  • Stable function references: ::myFunction
  • Parameters proven constant across call sites

Examples

❌ Bad
@Composable fun MyScreen() { val constantValue = rememberUpdatedState(42) // ❌ Constant value val constantList = rememberUpdatedState(listOf(1, 2, 3)) // ❌ Immutable list // Use regular remember or hoist instead }
✅ Good
@Composable fun MyScreen(onClick: () -> Unit) { // ✅ Use rememberUpdatedState for changing callbacks val currentOnClick = rememberUpdatedState(onClick) LaunchedEffect(Unit) { // currentOnClick.value will always reference the latest onClick // even if the composable recomposes with a new onClick } // ✅ Use remember for constants val constantValue = remember { 42 } // ✅ Or hoist to top level // private val CONSTANT_VALUE = 42 }

Exclusions

Lambdas/callbacks that are expected to change across recompositions are excluded, as rememberUpdatedState is the correct choice for these.

Threshold

Per occurrence: Any qualifying usage is reported.

10. Reused Key in Nested Scope

ReusedKeyInNestedScope Recomposition Semantics

What it detects: The same key expression being reused redundantly across nested recomposition or effect scopes (parent → child, or passed as parameter and reused).

Why it matters

When a parent scope already uses a key, reusing the same key in a nested child scope is redundant. Child scopes are already invalidated when parent keys change, so the nested key adds no value and obscures recomposition boundaries.

Targeted APIs

  • key(...)
  • LaunchedEffect(...)
  • DisposableEffect(...)
  • produceState(...)

Detection logic

  1. Extract key expressions (non-lambda arguments) from all scopes
  2. Traverse ancestor scopes: if a parent key intersects with the current key → violation
  3. Inter-procedural check: detect when a parent key variable is passed to a child composable and reused as a key deeper in the call tree

Examples

❌ Bad
@Composable fun ParentScreen(userId: String) { key(userId) { // Parent key LaunchedEffect(userId) { // ❌ Redundant - already keyed by parent // ... } ChildComposable(userId) // If this also uses userId as key, violation } } @Composable fun ChildComposable(userId: String) { key(userId) { // ❌ Redundant if parent already keyed by userId // ... } }
✅ Good
@Composable fun ParentScreen(userId: String) { key(userId) { // Parent key LaunchedEffect(Unit) { // ✅ No redundant key // Use userId directly - already scoped by parent key } ChildComposable(userId) // Pass as parameter, no key needed } } @Composable fun ChildComposable(userId: String) { // ✅ No key needed - already scoped by parent Text("User: $userId") }

Threshold

Binary detection: Any repeated key reuse across nested scopes is reported. First violation is reported to avoid noise.

11. Side-Effect Complexity (SEC)

SideEffectComplexityIssue Side-Effect Orchestration

What it detects: Side-effect blocks (LaunchedEffect, DisposableEffect, etc.) whose internal complexity indicates misuse of effect APIs for business or orchestration logic.

Why it matters

Side-effects should be simple lifecycle hooks. When they contain complex logic, it harms testability, obscures lifecycle semantics, and increases recomposition unpredictability. Complex logic belongs in ViewModels or use cases.

Note: SEC is also used as a component metric in CFC calculation.

Side-effects analyzed

  • LaunchedEffect, DisposableEffect, SideEffect
  • produceState, produceRetainedState
  • Restartable nested launches/async inside effects

Metrics collected (inside effect lambda)

Metric Description
branches Count of if and when statements
loops Count of for, while, do-while loops
maxDepth Maximum nesting depth of control structures
launchedScopes Nested launch, async, or restartable scopes
statements Approximate size via recursive statement counting

Formula

SEC = 2×branches + 3×loops + 2×maxDepth + 3×launchedScopes + floor(statements/10)

Threshold

Report when: SEC ≥ 10

Examples

❌ Bad
@Composable fun MyScreen(viewModel: MyViewModel) { LaunchedEffect(Unit) { // ❌ Complex business logic in effect var retries = 0 while (retries < 3) { try { if (viewModel.shouldFetch()) { val data = viewModel.fetchData() if (data.isNotEmpty()) { viewModel.processData(data) for (item in data) { viewModel.validateItem(item) } } } break } catch (e: Exception) { retries++ } } } }
✅ Good
@Composable fun MyScreen(viewModel: MyViewModel) { // ✅ Move complex logic to ViewModel LaunchedEffect(Unit) { viewModel.initialize() // Simple call to ViewModel method } } // In ViewModel: class MyViewModel { suspend fun initialize() { // Complex logic here - testable, reusable var retries = 0 while (retries < 3) { // ... } } }

Reporting scope

Reported on the specific side-effect call site (not the composable), with a full metric breakdown showing branches, loops, depth, launchedScopes, and statements.

12. Slot Count in Composable

SlotCountInComposable API Design

What it detects: Composables that expose too many composable lambda parameters ("slots"), increasing cognitive load and reducing API discoverability.

Why it matters

While not a runtime correctness issue, excessive slots make composable APIs harder to understand and use. It suggests the component should be decomposed into smaller, more focused composables.

What counts as a slot

A parameter counts as a slot if:

  1. It is a function/lambda type, and
  2. It is annotated with @Composable

Examples

❌ Bad
@Composable fun ComplexCard( header: @Composable () -> Unit, // Slot 1 title: @Composable () -> Unit, // Slot 2 subtitle: @Composable () -> Unit, // Slot 3 content: @Composable () -> Unit, // Slot 4 ❌ footer: @Composable () -> Unit, // Slot 5 ❌ actions: @Composable RowScope.() -> Unit // Slot 6 ❌ ) { // Too many slots - hard to remember and use }
✅ Good
// Option 1: Reduce slots by grouping @Composable fun Card( header: @Composable () -> Unit, // Slot 1 content: @Composable () -> Unit, // Slot 2 footer: @Composable () -> Unit // Slot 3 ✅ ) { // ... } // Option 2: Use a data class for related slots data class CardHeader( val title: String, val subtitle: String ) @Composable fun Card( header: CardHeader, // Not a slot content: @Composable () -> Unit, // Slot 1 actions: @Composable () -> Unit // Slot 2 ✅ ) { // ... }

Threshold

Maximum allowed: 3 slots per composable
Report when: slotCount > 3 (configurable per-project)

Severity

Low-Medium - This is an API design smell, not a runtime correctness defect. The code will work, but the API may be harder to use.

How to fix

  • Group related slots into data classes or sealed classes
  • Extract some slots into separate composable parameters (non-composable)
  • Break the composable into smaller components
  • Use builder pattern or DSL for complex configurations

13. Reactive State Pass-Through

ReactiveStatePassThrough Recomposition Efficiency & State Handling

What it detects: Composables that receive reactive state (or values derived from reactive state) as parameters but do not consume or transform them—only passing them unchanged to a single child. The detector reports only when the same parameter is passed through at least two consecutive composable layers without being used (a chain of ≥ 2 pass-throughs).

Why it matters

Passing reactive objects (State<T>, Flow<T>, StateFlow<T>, etc.) or state-derived values through multiple intermediate composables without use increases recomposition scope, violates proper state ownership, and makes the data flow harder to reason about. State should be collected or consumed closer to where it is used, or passed as immutable snapshots.

Reactive types considered

The detector treats the following parameter types as reactive state:

Type Description
State<T> / MutableState<T>Compose runtime state
StateFlow<T> / MutableStateFlow<T>Kotlin Flow hot stream
Flow<T>Kotlin Flow cold stream
LiveData<T>Android LiveData
Values from mutableStateOfe.g. var x by remember { mutableStateOf(0) } passed as Int
Values from collectAsState / collectAsStateWithLifecycleCollected State<T> passed to children

Pass-through vs consumption

Pass-through (not used) Consumption (used)
Parameter only passed as a direct argument to one child composable Parameter used in any other way: .value, .toString(), .let { }, collectAsState, string interpolation, property access, etc.

Chain rule

Report when: There is a chain of ≥ 2 consecutive composables that each pass the same reactive parameter through to a single child without consuming it. A single pass-through (chain of 1) is not reported.

Origin and secondary locations

When the reactive state originates from a local variable (e.g. mutableStateOf, collectAsState, collectAsStateWithLifecycle), the report can include a secondary location pointing to the creation point (e.g. val state = flow.collectAsState(...)) to help locate where the state was introduced.

Examples

❌ Bad
@Composable fun LayerA(flow: Flow<UiState>) { LayerB(flow) // Pass-through } @Composable fun LayerB(flow: Flow<UiState>) { LayerC(flow) // Pass-through } @Composable fun LayerC(flow: Flow<UiState>) { val state = flow.collectAsState(UiState("")) // Only LayerC consumes Text(state.value.title) } // LayerA and LayerB are flagged: chain of 2 pass-throughs
✅ Good
// Option 1: Collect at the top, pass State through one layer (chain of 1 — not reported) @Composable fun Parent(stateFlow: StateFlow<UiState>) { val state = stateFlow.collectAsState(UiState("")) Child(state) // Single pass-through OK } @Composable fun Child(state: State<UiState>) { Text(state.value.title) } // Option 2: Collect close to usage @Composable fun LayerA(flow: Flow<UiState>) { val state = flow.collectAsState(UiState("")) // Consumed here LayerB(state) // Pass immutable snapshot or derived state if needed }

How to fix

  • Move state collection (collectAsState, collectAsStateWithLifecycle) closer to the composable that actually uses the state
  • Pass immutable snapshots (e.g. state.value) instead of the reactive object when only a snapshot is needed downstream
  • Derive specific state (e.g. derivedStateOf) at the layer that needs it instead of passing the full reactive object through
  • Eliminate unnecessary intermediate composables that only forward the parameter

14. Non-Snapshot-Aware Collections in State

NonSnapshotAwareCollectionInState Recomposition Efficiency & State Handling

What it detects: Standard Kotlin collections (MutableList, List, Map, MutableMap, Set, MutableSet, ArrayList, HashMap, HashSet) stored inside Compose state (mutableStateOf, remember { mutableStateOf(...) }) and then mutated in-place (e.g. .add(), .remove(), .put(), .clear()). Also detects when such state is passed as a composable parameter and mutated in-place in the receiving composable.

Why it matters

Compose's snapshot system observes the state holder (the reference), not internal mutations inside non-snapshot collections. Mutating the contents of a list or map stored in state (e.g. items.value.add(1)) does not trigger recomposition, so the UI will not update. Snapshot-aware collections (mutableStateListOf(), mutableStateMapOf()) or reassigning a new collection (state.value = state.value + newItem) are observed and trigger recomposition correctly.

What counts as non-snapshot collection state

  • State created with mutableStateOf(mutableListOf(...)), mutableStateOf(arrayListOf(...)), mutableStateOf(mutableMapOf(...)), mutableStateOf(mutableSetOf(...)), etc.
  • Delegated state: var items by remember { mutableStateOf(mutableListOf(...)) } — mutations like items.add(...) are flagged.
  • Parameters of type MutableState<List<T>>, MutableState<MutableList<T>>, MutableState<Map<K,V>>, etc. — in-place mutation in the receiving composable is flagged.

Excluded (not reported)

  • mutableStateListOf(), mutableStateMapOf() — snapshot-aware; in-place mutations are observed.
  • Reassignment instead of mutation: items.value = items.value + 3 or items.value = items.value.toMutableList().apply { add(x) }.

Mutation methods that trigger a report

List/MutableList: add, addAll, remove, removeAll, removeAt, clear, retainAll, set, sort, shuffle, replaceAll

Map/MutableMap: put, putAll, remove, clear, replace, compute, computeIfAbsent, computeIfPresent, merge

Set/MutableSet: add, addAll, remove, removeAll, clear, retainAll

Examples

❌ Bad
@Composable fun Screen() { val items = remember { mutableStateOf(mutableListOf<Int>()) } items.value.add(1) // ❌ In-place mutation — no recomposition } @Composable fun Child(items: MutableState<MutableList<Int>>) { LaunchedEffect(Unit) { items.value.add(2) // ❌ Same issue when state is passed as parameter } }
✅ Good
@Composable fun Screen() { val items = remember { mutableStateListOf<Int>() } // ✅ Snapshot-aware items.add(1) // ✅ Triggers recomposition } // Or use reassignment instead of mutation @Composable fun Screen() { val items = remember { mutableStateOf(listOf(1, 2)) } items.value = items.value + 3 // ✅ Reassignment — triggers recomposition }

Threshold

Per occurrence: Any in-place mutation of a non-snapshot collection stored in state (or passed as parameter) is reported at the mutation call site.

How to fix

  • Use mutableStateListOf() or mutableStateMapOf() for list/map state that you need to mutate in-place.
  • Or keep mutableStateOf(listOf(...)) and update by reassignment: state.value = state.value + newItem or state.value = state.value.toMutableList().apply { add(x) }.
  • When passing state to child composables, ensure the child does not mutate the collection in-place; use snapshot-aware types or reassignment in the child as well.

Appendix: Metric Definitions

Quick reference for all metrics used across the smell detectors.

Metric Definition
branches Count of conditional branch points (if/else-if, and when entries excluding else).
loops Count of loop constructs; includes imperative loops (for, while, do-while) and selected functional iteration calls (e.g., forEach, map, filter).
maxDepth Maximum nesting depth of control structures (branches/loops/try-catch) within the analyzed scope.
sideEffectCount Number of side-effect blocks detected inside a composable (e.g., LaunchedEffect, DisposableEffect, SideEffect, produceState, etc.).
uiNodeCount Number of composable UI calls used to render UI (render-layer composable invocations like Text, Button, Column). Used by SED metric. Note: This is not raw statement count.
SEC_total Sum of SEC (Side-Effect Complexity) values across all side-effect blocks in a composable. Used as an input to CFC calculation.
viewModelAccesses Count of ViewModel access expressions inside the composable (e.g., direct property reads like viewModel.state or method calls like viewModel.loadData()).
controlFlowCount Total count of control flow constructs (branches + loops + break/continue/throw + try/catch). Used in LIU calculation.
totalStatements Approximate count of statements in the composable body. Used in LIU density calculation.