Lesson 21 of 83 advanced

Compose Performance: Recomposition Traps, Keys & Stability

Know why Compose skips recomposition, when it fails to skip, and how to fix it — the difference between a smooth 60fps UI and a janky one

Open interactive version (quiz + challenge)

Real-world analogy

Recomposition in Compose is like a smart photo editor that only re-renders the layers that actually changed. @Stable and @Immutable are like telling the editor 'this layer never changes — skip it.' Unstable parameters are like a layer whose content is unknown — the editor must re-render it every time just to be safe. key() is like naming each layer — without names, adding a new layer causes the editor to shift everything and re-render all of them.

What is it?

Compose performance centers on recomposition — how selectively Compose re-runs composable functions. Composables are skipped (not recomposed) when all their parameters are stable and unchanged. @Stable and @Immutable annotations inform the compiler about stability contracts. Unstable types like stdlib List break skipping. key() provides list item identity. derivedStateOf prevents reading rapidly-changing state directly. The Compose compiler metrics and Layout Inspector are the diagnostic tools.

Real-world relevance

In a fintech app displaying a real-time transaction list, each TransactionRow composable must be skippable to maintain 60fps scrolling while prices update. Using ImmutableList instead of List, annotating the Transaction data class with @Immutable, passing a remembered lambda for the tap handler, and keying the LazyColumn items by transaction ID together eliminate unnecessary recompositions. Layout Inspector confirms rows not affected by a single price update are skipped entirely.

Key points

Code example

// Unstable param — composable is NOT skippable
@Composable
fun TransactionList(transactions: List<Transaction>) { // List<T> is unstable
    LazyColumn {
        items(transactions) { tx -> TransactionRow(tx) }  // No key — bad
    }
}

// Fixed: ImmutableList + key + @Immutable data class
@Immutable
data class Transaction(val id: String, val amount: Double, val label: String)

@Composable
fun TransactionListFixed(transactions: ImmutableList<Transaction>) {  // Stable
    LazyColumn {
        items(transactions, key = { it.id }) { tx ->   // Stable key
            TransactionRow(tx)
        }
    }
}

// Lambda stability — new lambda on every recomposition = child not skippable
@Composable
fun BadParent(viewModel: TxViewModel = hiltViewModel()) {
    TransactionRow(
        tx = viewModel.tx,
        onClick = { viewModel.select(viewModel.tx.id) }  // New lambda every recomposition
    )
}

@Composable
fun GoodParent(viewModel: TxViewModel = hiltViewModel()) {
    val tx by viewModel.selectedTx.collectAsStateWithLifecycle()
    val onSelect = remember(tx.id) { { viewModel.select(tx.id) } }  // Stable lambda
    TransactionRow(tx = tx, onClick = onSelect)
}

// derivedStateOf — FAB visibility without recomposing on every scroll frame
@Composable
fun TransactionScreen() {
    val listState = rememberLazyListState()

    // BAD: reads listState.firstVisibleItemIndex — recomposes on EVERY scroll frame
    // val showFab = listState.firstVisibleItemIndex > 0

    // GOOD: only recomposes when crossing the 0 threshold
    val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }

    Box {
        LazyColumn(state = listState) { /* items */ }
        if (showFab) {
            FloatingActionButton(onClick = { /* scroll to top */ }) {
                Icon(Icons.Default.ArrowUpward, contentDescription = "Top")
            }
        }
    }
}

// @Stable on a class with observable mutation
@Stable
class FilterState {
    var query by mutableStateOf("")    // Changes notify Compose via snapshot
    var sortAscending by mutableStateOf(true)
}

// Wrapper for unstable stdlib collections
@Immutable
data class TransactionList(val items: List<Transaction>)  // Wraps unstable List

// Compose compiler metrics — build.gradle.kts
// android {
//     composeOptions {
//         kotlinCompilerExtensionVersion = "..."
//     }
// }
// tasks.withType<KotlinCompile>().configureEach {
//     kotlinOptions.freeCompilerArgs += listOf(
//         "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${rootProject.buildDir}/compose-metrics"
//     )
// }

Line-by-line walkthrough

  1. 1. List as a parameter type makes TransactionList non-skippable — Compose cannot prove the list is stable
  2. 2. @Immutable on Transaction promises the compiler: fields never change after construction — Compose will always skip equal instances
  3. 3. ImmutableList from kotlinx.collections.immutable is recognized as stable — passing it as a parameter restores skippability
  4. 4. items(transactions, key = { it.id }) — the key lambda provides stable identity; Compose tracks each item by ID, not by position
  5. 5. BadParent's onClick = { viewModel.select(...) } creates a new lambda object every recomposition — TransactionRow receives a new parameter and cannot skip
  6. 6. GoodParent wraps the lambda in remember(tx.id) — the same lambda instance is returned as long as tx.id does not change
  7. 7. val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } — reads scrollState inside derivedStateOf, which only notifies subscribers when the boolean result changes
  8. 8. Without derivedStateOf, reading listState.firstVisibleItemIndex directly in the composable body subscribes to every scroll event — recomposing the entire Box on every pixel scrolled
  9. 9. @Stable FilterState uses mutableStateOf fields — mutations go through the Compose snapshot system, which notifies only the composables that read the changed fields
  10. 10. The commented-out compiler metrics config shows how to enable the -P plugin flag that generates JSON reports listing every composable's stability and skippability status

Spot the bug

@Composable
fun PortfolioScreen(viewModel: PortfolioViewModel = hiltViewModel()) {
    val stocks by viewModel.stocks.collectAsStateWithLifecycle()  // StateFlow<List<Stock>>

    // Bug 1
    val gainers = stocks.filter { it.change > 0 }

    Column {
        // Bug 2
        Text(
            text = "Total: ${stocks.sumOf { it.price }}",
            style = MaterialTheme.typography.headlineMedium
        )

        LazyColumn {
            // Bug 3
            items(gainers) { stock ->
                StockRow(
                    stock = stock,
                    // Bug 4
                    onTap = { viewModel.selectStock(stock.id) }
                )
            }
        }
    }
}

data class Stock(val id: String, val ticker: String, val price: Double, val change: Double)
Need a hint?
Consider: where expensive computations happen, key for list items, lambda stability, and List stability.
Show answer
Bug 1: stocks.filter { it.change > 0 } runs inline in the composable body — it executes on EVERY recomposition (including ones triggered by unrelated state like theme changes). Fix: val gainers by remember { derivedStateOf { stocks.filter { it.change > 0 } } }. Bug 2: stocks.sumOf { it.price } also runs on every recomposition. Fix: wrap in derivedStateOf or compute in the ViewModel. Bug 3: items(gainers) has no key — if a stock is removed from the middle, Compose shifts all items and may reuse composable state for wrong stocks. Fix: items(gainers, key = { it.id }). Bug 4: onTap = { viewModel.selectStock(stock.id) } creates a new lambda instance on every recomposition, making StockRow non-skippable. Fix: val onTap = remember(stock.id) { { viewModel.selectStock(stock.id) } } declared before the LazyColumn. Additionally, the Stock data class is not annotated @Immutable and stocks is List<Stock> (unstable) — annotate Stock with @Immutable and use ImmutableList<Stock> in the StateFlow for full skippability.

Explain like I'm 5

Think of Compose like a smart painter who repaints only what changed. If you give the painter a paint bucket with a sticky note saying 'this color never changes' (@Immutable), they skip that part entirely. But if you hand them an unmarked mystery bucket (List), they repaint it just in case — even if it's the same color. key() is like labeling your paintings with names — so when you add a new painting to the gallery, the others don't get confused and mixed up.

Fun fact

The Compose compiler is a Kotlin compiler plugin that rewrites your @Composable functions at compile time, injecting the Composer parameter and gap buffer calls. When it determines a composable is 'skippable', it wraps the body in an if-block that compares parameters before executing. You can see this generated code by decompiling the bytecode — the skip check is literally an if (changed == 0) return inside the generated function.

Hands-on challenge

Take an existing screen with a LazyColumn of 200 items where items render incorrectly after filtering (composable state bleeds between items), and the whole list recomposes when a header text changes. Fix: add keys to items, annotate the item data class with @Immutable, replace List with ImmutableList, memoize lambdas with remember. Then use Layout Inspector to confirm recomposition counts drop. Document the before/after recomposition counts.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery