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.
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.
Modifier parameter.