Overview
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
Quick Reference Table
| Smell | Detector ID | Primary Metric | Threshold | Category |
|---|---|---|---|---|
| CFC | ComposableFunctionComplexityIssue | Weighted complexity | CFC ≥ 25 or loops > 4 | Architecture |
| Constants in Composable | ConstantsInComposable | Occurrence | Any occurrence | Recomposition |
| SED | HighSideEffectDensityIssue | sideEffectCount/uiNodeCount | SED ≥ 0.30 and sideEffectCount ≥ 2 | Side-Effects |
| LIU | LogicInUIIssue | Control-flow density | LIU > 0.30 | Architecture |
| Multiple Flows | MultipleFlowCollectionsPerComposable | Flow count | > 2 | Architecture |
| MutableState in Conditions | MutableStateInCondition | Occurrence | Any occurrence | Recomposition |
| State Mutation | MutableStateMutationInComposable | Occurrence | Any occurrence | State |
| Non-Savable Type | NonSavableRememberSaveable | Occurrence | Any occurrence | State |
| rememberUpdatedState | RememberUpdatedStateWithConstant | Occurrence | Any occurrence | Recomposition |
| Reused Key | ReusedKeyInNestedScope | Binary | Any nested reuse | Recomposition |
| SEC | SideEffectComplexityIssue | Effect complexity | SEC ≥ 10 | Side-Effects |
| Slot Count | SlotCountInComposable | Slot count | > 3 | API Design |
| Reactive State Pass-Through | ReactiveStatePassThrough | Pass-through chain length | chain ≥ 2 | Recomposition |
| Non-Snapshot-Aware Collections in State | NonSnapshotAwareCollectionInState | Occurrence | In-place mutation on state-held collection | Recomposition |
Smell Catalog
1. Composable Function Complexity (CFC)
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
Thresholds
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
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
Threshold
remember) is reported.
var declarations are excluded, as they're expected to change. Constants inside remember { } blocks are also excluded.
3. High Side-Effect Density (SED)
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,SideEffectproduceState,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
Thresholds
sideEffectCount ≥ 2SED ≥ 0.30(30% of the composable is side-effects)
Additional diagnostics
The detector also reports when:
- Duplicate
LaunchedEffectkeys 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)
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-ifwhenentries (excludingelse)- Loops:
for,while,do-while, and functional loops break,continue,throwtry/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
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
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
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
Report when:
flowCollectionCount > 2
Examples
How to fix
- Aggregate multiple flows into a single state class in the ViewModel
- Use
combineorflowOfto 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
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
Allowed patterns
if (myBooleanState)when the type isMutableState<Boolean>(direct boolean state)whenover enums (stable values)- Use of
derivedStateOfto precompute the condition
Threshold
7. MutableState Mutation in Composable
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
Allowed patterns
Mutations are allowed (and expected) in:
- Callbacks:
onClick,onTextChanged, etc. - Side-effect blocks:
LaunchedEffect,DisposableEffect, etc.
Threshold
How to fix
- Move state mutations to event handlers (onClick, etc.)
- If initialization is needed, use
LaunchedEffectorDisposableEffect - Use state hoisting to manage state in a parent composable or ViewModel
8. Non-Savable Type in rememberSaveable
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
ParcelabletypesSerializabletypes- Types annotated with
@Serializable(Kotlinx Serialization)
Examples
Threshold
9. rememberUpdatedState with Constant
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 valdeclarations- Stable function references:
::myFunction - Parameters proven constant across call sites
Examples
Exclusions
Lambdas/callbacks that are expected to change across recompositions are excluded, as rememberUpdatedState is the correct choice for these.
Threshold
10. Reused Key in Nested Scope
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
- Extract key expressions (non-lambda arguments) from all scopes
- Traverse ancestor scopes: if a parent key intersects with the current key → violation
- 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
Threshold
11. Side-Effect Complexity (SEC)
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,SideEffectproduceState,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
Threshold
SEC ≥ 10
Examples
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
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:
- It is a function/lambda type, and
- It is annotated with
@Composable
Examples
Threshold
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
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 mutableStateOf | e.g. var x by remember { mutableStateOf(0) } passed as Int |
Values from collectAsState / collectAsStateWithLifecycle | Collected 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
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
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
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 likeitems.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 + 3oritems.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
Threshold
How to fix
- Use
mutableStateListOf()ormutableStateMapOf()for list/map state that you need to mutate in-place. - Or keep
mutableStateOf(listOf(...))and update by reassignment:state.value = state.value + newItemorstate.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. |