Avoiding Hidden Recomposition Traps in Compose

Dhruval Dhameliya·January 4, 2026·9 min read

Practical catalog of non-obvious Compose patterns that trigger excessive recomposition, with fixes and measurement strategies for each.

Context

Compose's recomposition model is designed to skip work when inputs have not changed. In practice, several common Kotlin patterns silently defeat this optimization. These traps are "hidden" because the code looks correct, the UI renders properly, and the problem only manifests as degraded frame rates on real devices under load.

Problem

A composable that should be skipped recomposes anyway. The visual output is identical, but the runtime re-executes the function, re-evaluates its children, and wastes frame budget. In isolation, one unnecessary recomposition costs microseconds. In a LazyColumn with 50 visible items, each recomposing unnecessarily, the cost compounds into dropped frames.

Constraints

  • The Compose compiler determines stability at compile time, not runtime
  • Lambda equality in Kotlin is reference-based, not structural
  • Default parameter values are re-evaluated on every call
  • Inline composable functions cannot be recomposition scopes
  • The compiler treats types from external modules as unstable unless explicitly declared

Design

Trap 1: Lambda Allocations

Every lambda that captures a variable creates a new object on each recomposition. If that lambda is passed as a parameter, the receiving composable sees a new reference and cannot skip.

// Trap: new lambda on every recomposition of ParentScreen
@Composable
fun ParentScreen(viewModel: ParentViewModel) {
    val items by viewModel.items.collectAsState()
 
    ItemList(
        items = items,
        onItemClick = { id -> viewModel.onItemClick(id) } // New lambda every recomposition
    )
}
 
// Fix 1: use a method reference (stable if viewModel is stable)
@Composable
fun ParentScreen(viewModel: ParentViewModel) {
    val items by viewModel.items.collectAsState()
 
    ItemList(
        items = items,
        onItemClick = viewModel::onItemClick
    )
}
 
// Fix 2: remember the lambda
@Composable
fun ParentScreen(viewModel: ParentViewModel) {
    val items by viewModel.items.collectAsState()
    val onItemClick = remember<(String) -> Unit> { { id -> viewModel.onItemClick(id) } }
 
    ItemList(
        items = items,
        onItemClick = onItemClick
    )
}

The Compose compiler can sometimes optimize non-capturing lambdas into singletons. But if the lambda captures any local variable, it allocates on every call.

Trap 2: Collections as Parameters

List, Set, and Map from the Kotlin standard library are interfaces. The compiler cannot prove they are immutable, so it marks them as unstable.

// Trap: List<String> is unstable, FormatPanel always recomposes
@Composable
fun FormatPanel(options: List<String>, selected: String) {
    // Even if options has the same contents, it's a new List reference
    Row {
        options.forEach { option ->
            Chip(text = option, isSelected = option == selected)
        }
    }
}
 
// Fix: use ImmutableList
@Composable
fun FormatPanel(options: ImmutableList<String>, selected: String) {
    Row {
        options.forEach { option ->
            Chip(text = option, isSelected = option == selected)
        }
    }
}
TypeStable?Alternative
List<T>NoImmutableList<T> or PersistentList<T>
Set<T>NoImmutableSet<T> or PersistentSet<T>
Map<K,V>NoImmutableMap<K,V> or PersistentMap<K,V>
Array<T>NoConvert to ImmutableList<T>
StringYesN/A
Int, Float, primitivesYesN/A

Trap 3: Data Classes with Unstable Fields

A data class is stable only if all its fields are stable. One unstable field makes the entire class unstable.

// Unstable: `tags` is List<String>
data class Article(
    val id: String,
    val title: String,
    val tags: List<String>,       // Makes entire class unstable
    val publishedAt: Instant      // Instant from java.time is unstable
)
 
// Fixed
@Immutable
data class Article(
    val id: String,
    val title: String,
    val tags: ImmutableList<String>,
    val publishedAt: Long              // Use primitive timestamp
)

Alternatively, declare external types as stable in the compiler configuration:

// compose_compiler_config.conf
java.time.Instant
java.time.LocalDate
kotlinx.datetime.Instant

Trap 4: State Hoisting at the Wrong Level

Reading state too high in the tree causes a large subtree to recompose when only a small part needs updating.

// Trap: entire screen recomposes when scrollOffset changes
@Composable
fun ProductScreen(viewModel: ProductViewModel) {
    val scrollOffset by viewModel.scrollOffset.collectAsState()
    val product by viewModel.product.collectAsState()
 
    Column {
        CollapsingHeader(offset = scrollOffset) // Needs scrollOffset
        ProductDetails(product = product)        // Does NOT need scrollOffset
        ReviewsList(productId = product.id)      // Does NOT need scrollOffset
    }
}
 
// Fix: isolate the state read
@Composable
fun ProductScreen(viewModel: ProductViewModel) {
    val product by viewModel.product.collectAsState()
 
    Column {
        // scrollOffset is read inside CollapsingHeader only
        CollapsingHeader(viewModel = viewModel)
        ProductDetails(product = product)
        ReviewsList(productId = product.id)
    }
}
 
@Composable
fun CollapsingHeader(viewModel: ProductViewModel) {
    val scrollOffset by viewModel.scrollOffset.collectAsState()
    // Only this composable recomposes on scroll
    TopAppBar(
        modifier = Modifier.graphicsLayer { translationY = -scrollOffset }
    )
}

Trap 5: Modifier.then() with New Instances

Creating modifier instances inside composition allocates new objects on every recomposition.

// Trap: new Modifier chain on every recomposition
@Composable
fun AnimatedCard(elevation: Dp) {
    Card(
        modifier = Modifier
            .padding(16.dp)
            .shadow(elevation, RoundedCornerShape(8.dp)) // new RoundedCornerShape each time
    ) {
        // ...
    }
}
 
// Fix: hoist the shape
private val CardShape = RoundedCornerShape(8.dp)
 
@Composable
fun AnimatedCard(elevation: Dp) {
    Card(
        modifier = Modifier
            .padding(16.dp)
            .shadow(elevation, CardShape)
    ) {
        // ...
    }
}

Trap 6: derivedStateOf Without Proper Keys

derivedStateOf caches a derived value and only triggers recomposition when the derived value changes. But if used incorrectly, it can cause stale reads or unnecessary recalculations.

// Trap: derivedStateOf recalculates on every composition because
// the lambda captures a recomposition-scoped variable
@Composable
fun SearchResults(query: String, items: ImmutableList<Item>) {
    // This creates a new derivedStateOf on every recomposition
    val filtered = remember {
        derivedStateOf { items.filter { it.name.contains(query) } }
    }
    // ...
}
 
// Fix: use remember with keys, or ensure derivedStateOf reads State objects
@Composable
fun SearchResults(query: String, items: ImmutableList<Item>) {
    val filtered = remember(query, items) {
        items.filter { it.name.contains(query) }
    }
    // ...
}

derivedStateOf is most useful when you have a State object that changes frequently but the derived value changes infrequently (e.g., scroll position to "show FAB" boolean).

Trap 7: Flow Collection Creating New State

// Trap: collectAsState on a new Flow every recomposition
@Composable
fun UserProfile(userId: String, repository: UserRepository) {
    // repository.getUser(userId) creates a new Flow each call
    // collectAsState subscribes to a new Flow on every recomposition
    val user by repository.getUser(userId).collectAsState(initial = null)
    // ...
}
 
// Fix: remember the Flow
@Composable
fun UserProfile(userId: String, repository: UserRepository) {
    val userFlow = remember(userId) { repository.getUser(userId) }
    val user by userFlow.collectAsState(initial = null)
    // ...
}

Trade-offs

FixBenefitCost
ImmutableList everywhereEnables skipping for collection paramsConversion overhead at data layer boundary
Remember lambdasStable references prevent child recompositionMore verbose code, potential stale captures
Compiler stability configMarks external types as stableMust be maintained as dependencies change
Push state reads downSmaller recomposition scopeMore composable functions, slightly deeper call stack

Failure Modes

Related: Designing Event Schemas That Survive Product Changes.

FailureSymptomDetection
Forgotten lambda allocation in LazyColumn itemAll items recompose on any state changeLayout Inspector shows high recomposition counts
Unstable data class in hot pathFrame drops during scrollingCompose compiler metrics show "unstable" for the class
derivedStateOf with wrong capturesStale UI or unnecessary recompositionUnit test comparing expected vs actual recomposition count
Flow re-creation on recompositionFlicker as state resets to initial valueVisible UI reset, logcat showing repeated initial emissions

Scaling Considerations

  • Code review checklist: add stability checks to PR review. Flag List params on composables in scrollable containers
  • Lint rules: write custom lint checks to detect List parameters on composable functions
  • Module boundaries: types crossing module boundaries lose stability inference. Centralize shared UI models in a common module with @Immutable annotations
  • Compose compiler reports: generate on every PR and diff against the baseline. Any composable that becomes non-skippable is a regression

Observability

  • Compose compiler metrics: -P plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=path generates composables.txt (skippability), classes.txt (stability)
  • Layout Inspector: shows recomposition count and skip count per composable in real time
  • Custom recomposition counter: track in debug builds
@Composable
inline fun TrackRecomposition(tag: String) {
    if (BuildConfig.DEBUG) {
        val count = remember { mutableIntStateOf(0) }
        SideEffect {
            count.intValue++
            if (count.intValue > 1) {
                Log.d("Recomposition", "$tag recomposed ${count.intValue} times")
            }
        }
    }
}

Key Takeaways

  • Lambda allocations are the most common hidden recomposition trigger. Use method references or remember for lambdas passed to child composables
  • Standard Kotlin collections (List, Set, Map) are unstable. Use kotlinx.collections.immutable for composable parameters
  • One unstable field in a data class makes the entire class unstable. Audit with compiler metrics
  • Read state in the narrowest possible scope. Do not hoist state reads above the composable that needs them
  • derivedStateOf is for frequent-input, infrequent-output scenarios. For everything else, use remember with keys

See also: Debugging Performance Issues in Large Android Apps.


Further Reading

Final Thoughts

These traps are not edge cases. They appear in virtually every Compose codebase. The insidious part is that the app works correctly, and the performance cost is invisible without measurement. Build the habit of checking Compose compiler metrics on every PR, running the Layout Inspector during development, and profiling with Perfetto before releases. The cost of fixing these traps is low. The cost of shipping them to production is measured in jank, poor vitals, and user churn.

Recommended