Performance & Stability

Understanding Recomposition, early in this track, established that Compose can skip recomposing a composable whose inputs haven't changed. This lesson covers the condition that has to be true for that skip to actually happen: the composable's parameters need to be stable. Get this wrong, and a screen that looks perfectly correct quietly recomposes far more than it needs to — usually invisible until a list has enough items for the wasted work to actually cost visible frame time.

What "Stable" Actually Means

Compose considers a type stable if it can guarantee: equal instances (by equals()) will always produce equal results, and whenever a public property of the type changes, Compose is notified (so it knows to recompose). If the compiler can prove a type meets that contract, it can safely skip recomposing a composable when a stable parameter is passed the same value as before. All primitive types (Int, Boolean, String, etc.) are stable, and the Compose compiler plugin can also infer stability for your own data classes automatically — as long as every property inside them is stable too.

What Breaks Stability

The two most common stability breakers in ordinary app code:

Breaks stabilityWhy
A var property with a public setterThe compiler can't guarantee Compose will be told when it changes from outside the composable
A plain List<T> parameterKotlin's List interface doesn't guarantee immutability — the same reference could secretly be a MutableList mutated in place, with no equality change Compose can observe

The fix for both is usually the same: use val instead of var for properties in a data class, and prefer an explicitly immutable collection type (like Kotlinx Immutable Collections' ImmutableList) over a raw List when a collection is passed as a composable parameter and needs to be treated as stable.

Before/After: A RecipeBox List Item That Recomposes Too Often

Say RecipeRow takes a tags: List<String> parameter for a recipe's tags, and an onClick lambda captured fresh on every parent recomposition:

Before — unstable parameters

// Both parameters are unstable, for different reasons
@Composable
fun RecipeRow(recipe: Recipe, tags: List<String>, onClick: () -> Unit) {
    // recomposes even when neither `recipe` nor `tags` actually changed
}

@Composable
fun RecipeListScreen(recipes: List<Recipe>, filterState: FilterState) {
    LazyColumn {
        items(items = recipes, key = { it.id }) { recipe ->
            // a NEW lambda instance is created here on every RecipeListScreen recomposition
            RecipeRow(recipe = recipe, tags = recipe.tags, onClick = { println("Clicked ${recipe.id}") })
        }
    }
}

Every time filterState changes and recomposes RecipeListScreen — even for something unrelated to any individual recipe, like toggling a sort order — a brand-new onClick lambda is created for every visible RecipeRow, and the raw List<String> for tags gives Compose no stability guarantee either. The result: every RecipeRow on screen recomposes on every unrelated state change, even though its own recipe data hasn't changed at all.

The fix: make the lambda stable by hoisting it out of the loop, and mark the tag list stable explicitly with @Immutable on a small wrapper (or by giving RecipeRow a stable data class instead of a raw List):

After — stable parameters

@Immutable
data class RecipeTags(val values: List<String>)

@Composable
fun RecipeRow(recipe: Recipe, tags: RecipeTags, onClick: (String) -> Unit) {
    // now skips recomposition when `recipe` and `tags` are unchanged
}

@Composable
fun RecipeListScreen(recipes: List<Recipe>, filterState: FilterState) {
    // hoisted ONCE — the same lambda instance is reused across recompositions
    val onRecipeClick: (String) -> Unit = { id -> println("Clicked $id") }

    LazyColumn {
        items(items = recipes, key = { it.id }) { recipe ->
            RecipeRow(recipe = recipe, tags = RecipeTags(recipe.tags), onClick = onRecipeClick)
        }
    }
}

@Immutable is a promise you make to the compiler: every property of this type is permanently unchanging once constructed, even if the compiler can't verify that itself (as it can't for a wrapped List). @Stable is the same idea for a type whose properties can change, but always in a way Compose is notified about — used far less often, typically on a class with observable state managed manually rather than through mutableStateOf. Both are escape hatches for cases the compiler can't infer on its own — reach for them only after confirming instability is the actual problem, not as a reflexive fix.

Confirming It: The Layout Inspector's Recomposition Counts

Don't guess at stability problems — Android Studio's Layout Inspector can show a live recomposition count next to each composable in a running app. Toggle an unrelated piece of state (like the sort order in the example above) and watch the counts: if every visible RecipeRow's count increments on a change that has nothing to do with that specific row, that's the concrete signal an instability fix like the one above is worth making — and re-checking those same counts afterward is exactly how you confirm the fix actually worked.

Note: This is correct, real Compose/Kotlin code — but this website's own project doesn't run a live Android Studio build, so the Layout Inspector's live counts described above can't be shown running on this page.
Next up: Not every screen in a growing app is built with Compose from day one. Interop with the View System covers mixing Compose and traditional Views in both directions.