Understanding Recomposition

The last lesson used the word "recomposition" without fully defining it — you saw that remember { mutableStateOf(...) } makes a favorite button work, and that Compose "re-invokes" a composable when its state changes. This lesson makes that mechanism precise, because understanding it correctly is what separates code that merely happens to work from code that's actually built the way Compose expects.

What Triggers Recomposition

Recomposition is Compose calling your composable function again to produce an updated description of the UI. It is triggered by exactly one thing: a State object that a composable read during its last composition has changed value. Compose tracks this automatically — every time a composable reads .value from a MutableState (or a delegated by property backed by one) while it runs, Compose records "this composable depends on this piece of state." When that state's value is later written to, Compose looks up every composable that read it and schedules each one to run again.

@Composable
fun RecipeSearchBar() {
    var query by remember { mutableStateOf("") }

    // Reading "query" here registers this composable as dependent on it
    TextField(
        value = query,
        onValueChange = { newQuery -> query = newQuery // writing triggers recomposition },
        placeholder = { Text("Search recipes...") }
    )
}

Nothing outside this state-read/state-write relationship causes recomposition. A parent recomposing doesn't automatically force every child to recompose either — which leads directly to the next point.

Smart Recomposition: Skipping Unaffected Composables

Compose doesn't treat the whole UI tree as one unit that redraws top to bottom on every change. It tracks, composable by composable, which ones actually read which state, and it can skip re-running a composable entirely if none of its inputs (parameters and any state it reads) have changed since last time. Consider a Recipe List screen:

@Composable
fun RecipeListScreen(recipes: List<Recipe>) {
    var query by remember { mutableStateOf("") }

    Column {
        RecipeSearchBar(query = query, onQueryChange = { query = it })
        // RecipeCard is only re-invoked for recipes whose own inputs changed —
        // typing in the search bar above does NOT re-run every RecipeCard
        recipes.forEach { recipe ->
            RecipeCard(title = recipe.title, prepMinutes = recipe.prepMinutes)
        }
    }
}

When query changes, only RecipeListScreen and RecipeSearchBar (the composables that actually read query) need to recompose. Every individual RecipeCard receives the same title and prepMinutes it had before, so Compose can skip re-running each one entirely. This is why Compose can afford to make recomposition happen so often and so casually — it's designed from the ground up to do the minimum necessary work, not to redraw everything on every change.

Composables Should Be Side-Effect-Free and Idempotent

Because Compose can call a composable function again at a time and frequency it chooses — skipping it, running it multiple times, or running it out of the order you might expect, all as internal optimizations — a composable function should be idempotent: calling it repeatedly with the same inputs must always produce the same UI description, with no side effects that only make sense if the function ran exactly once. Writing to a database, logging analytics, or mutating some object outside the composable directly in the function body are all examples of side effects that do not belong in the body of a composable — Compose provides dedicated tools for exactly this (LaunchedEffect, SideEffect, and friends), covered later in the Side Effects section of this track, precisely because the composable body itself is the wrong place for them.

Note: This isn't a stylistic preference — a composable that silently increments a shared counter every time it runs, for instance, would give a different, wrong result depending on how many times Compose happened to recompose it, which is exactly the kind of bug that's painful to track down.

The Common Mistake, Revisited

The previous lesson's broken FavoriteButton is worth revisiting now with the full picture. Here it is again, side by side with the fix:

// WRONG: plain var is reset to false on every recomposition
@Composable
fun FavoriteButton() {
    var isFavorite = false
    IconButton(onClick = { isFavorite = !isFavorite }) { /* ... */ }
}

// CORRECT: remember + mutableStateOf survives recomposition
@Composable
fun FavoriteButton() {
    var isFavorite by remember { mutableStateOf(false) }
    IconButton(onClick = { isFavorite = !isFavorite }) { /* ... */ }
}

The wrong version fails for exactly the mechanism this lesson covered: a plain var isn't a State object at all, so writing to it doesn't schedule recomposition, and even if something else nearby happened to trigger recomposition anyway, the plain var would just be re-declared and reset to false the moment the function re-ran. The fix uses remember so the same MutableState survives across recompositions, and because it genuinely is a tracked State object, writing to it is what triggers the next recomposition in the first place. Every composable you write from here forward should follow this same shape: local state that changes over time lives in remember { mutableStateOf(...) } (or a hoisted equivalent), never in a bare var.

Next up: With state and recomposition now solid, Modifiers covers the other pillar of everyday Compose UI — how to control layout, padding, size, and appearance of any composable through a single chainable Modifier parameter.