Taming Recomposition
Stable params and fewer recompositions.
Taming Recomposition is a free Android Academy lesson on CoddyKit — lesson 2 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Android Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Recomposition: The Compose Performance Knob
In Jetpack Compose, the UI is described by functions. When state changes, Compose recomposes: it re-runs the composables that read that state to update the screen.
Recomposition is normal and cheap when scoped well. But when it happens too often or over too much of the tree, it becomes the #1 cause of Compose jank. This lesson teaches you to keep recomposition small and rare.
Seeing Recomposition Counts
Before fixing, measure. The Layout Inspector in Android Studio shows recomposition counts per composable in real time. You can also use the Compose compiler's metrics, or a quick debug counter.
A simple trick: a SideEffect increments a ref each time a composable recomposes, so you can log surprises during development.
@Composable
fun RecompositionCounter(tag: String) {
val count = remember { mutableStateOf(0) }
SideEffect { count.value++ }
Log.d("Recompose", "$tag recomposed ${count.value} times")
}
// Drop RecompositionCounter("PriceLabel") inside a composable
// to watch how often it re-runs while you interact.Read State as Late as Possible
Compose only recomposes the composables that read a state value. If a parent reads the state, the whole parent recomposes; if only a small child reads it, only that child recomposes.
So push state reads down the tree. Here the bad version recomposes the whole Column on every tick; the good version isolates it to the label.
// BAD: Column reads `seconds`, so everything recomposes each second
@Composable
fun TimerBad(seconds: Int) {
Column {
ExpensiveHeader()
Text("Elapsed: $seconds")
}
}
// GOOD: only the Text reads the value via a lambda
@Composable
fun TimerGood(seconds: () -> Int) {
Column {
ExpensiveHeader()
Text("Elapsed: ${seconds()}")
}
}Defer Reads with Lambdas
A powerful pattern: instead of passing a value that changes often, pass a lambda that returns it. The composable that finally calls the lambda is the only one that recomposes.
This is why Modifier.offset { ... } and graphicsLayer { ... } take lambdas: scroll/animation values change every frame, and the lambda keeps recomposition out of the layout phase entirely.
// Passing the value: parent recomposes every frame of scroll
Box(Modifier.offset(y = scrollOffset.dp))
// Passing a lambda: skips recomposition, updates in the layout phase
Box(Modifier.offset { IntOffset(x = 0, y = scrollOffset.roundToInt()) })
// Same idea for alpha/scale during animation:
Image(
painter = painter,
contentDescription = null,
modifier = Modifier.graphicsLayer { alpha = animatedAlpha() }
)Stability: Why Compose Skips
Compose can skip recomposing a composable if all its parameters are stable and unchanged. A type is stable when Compose can trust that equals reflects real change and its public fields do not mutate unseen.
- Stable: primitives,
String, immutable data classes,State. - Unstable:
List/Mapinterfaces, classes withvarfields, types from modules without the compiler.
An unstable parameter forces recomposition even when nothing changed.
// UNSTABLE: List is an interface; Compose cannot assume immutability,
// so UserList recomposes even if the contents are identical.
@Composable
fun UserList(users: List<User>) { /* ... */ }
data class User(val id: Long, val name: String) // stable: all valsMake Parameters Stable
Two common fixes for unstable parameters:
- Use immutable collections from
kotlinx.collections.immutable(e.g.ImmutableList), which Compose treats as stable. - Annotate a class you control with
@Immutableor@Stableto promise Compose it will not change.
Now Compose can safely skip recomposition when the same instance is passed again.
import kotlinx.collections.immutable.ImmutableList
import androidx.compose.runtime.Immutable
@Immutable
data class UiState(
val title: String,
val users: ImmutableList<User>
)
// Stable parameter -> Compose can skip this when state is unchanged
@Composable
fun UserList(users: ImmutableList<User>) { /* ... */ }remember: Don't Recompute Every Frame
Composables can run many times. Any non-trivial calculation done directly in the body runs on every recomposition. Wrap it in remember so it only recomputes when its keys change.
For values derived from other state, prefer derivedStateOf, which only re-emits when the computed result actually changes.
// Recomputes the sorted list only when `items` changes
val sorted = remember(items) { items.sortedBy { it.name } }
// derivedStateOf: only triggers readers when the BOOLEAN flips,
// not on every scroll pixel
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
if (showButton) ScrollToTopButton()Stable Keys in Lazy Lists
In LazyColumn/LazyRow, give each item a stable key. Without keys, inserting or reordering items forces Compose to recompose and re-measure items that did not really change, because it tracks them by position.
With a stable key, Compose can match items across updates and skip the unchanged ones.
LazyColumn {
items(
items = users,
key = { user -> user.id } // stable identity
) { user ->
UserRow(user)
}
}
// Now adding a user at the top reuses existing rows
// instead of recomposing the whole list.Hoist State, Pass Events Up
Unstable lambda parameters can also break skipping if a new lambda instance is created on every recomposition. Stabilize callbacks by remembering them or referencing stable functions.
Combined with state hoisting (state lives in the caller, events flow up), this keeps leaf composables cheap and skippable.
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
TextField(value = query, onValueChange = onQueryChange)
}
// In the caller, a remembered lambda keeps the reference stable:
val onChange = remember { { newValue: String -> viewModel.setQuery(newValue) } }
SearchBar(query = query, onQueryChange = onChange)Avoid Reading Scroll/Animation State Too High
A classic mistake: reading a fast-changing state (scroll offset, animation progress) in a high-level composable. That forces the whole subtree to recompose every frame.
Keep such reads inside Modifier lambdas (offset {}, graphicsLayer {}, drawBehind {}) so the work happens in the layout or draw phase, not recomposition. This is the difference between a smooth 60fps scroll and a stuttery one.
// Animated color used only for drawing -> stay in the draw phase
Box(
Modifier.drawBehind {
drawRect(color = animatedColor()) // lambda read, no recomposition
}
)A Recomposition Checklist
When a screen feels janky during interaction, run through this list:
- Are parameters stable (immutable data,
ImmutableList)? - Are you reading fast-changing state as low as possible, ideally in modifier lambdas?
- Do lazy lists have stable keys?
- Are expensive computations behind
remember/derivedStateOf? - Did Layout Inspector confirm the count actually dropped?
Each box you tick removes wasted work from every frame.
Quick Check
You pass a List<User> to a composable and notice it recomposes even when the data is unchanged. What is the most direct fix?
Recap: Smaller, Rarer Recomposition
You learned to tame recomposition, the top Compose performance issue:
- Measure counts with Layout Inspector or a debug counter.
- Read state as low as possible; defer reads with lambdas.
- Make parameters stable with immutable data and
@Immutable/ImmutableList. - Use
rememberandderivedStateOfto avoid recomputing every frame. - Give lazy items stable keys; keep fast state reads in modifier lambdas.
Next we move from CPU/recomposition to memory: finding and fixing leaks.
Frequently asked questions
Is the “Taming Recomposition” lesson free?
Yes — the full text of “Taming Recomposition” is free to read here on the web, and the Android Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Android Academy course, upgrade to CoddyKit PRO.
What will I learn in “Taming Recomposition”?
Stable params and fewer recompositions. You practise Android Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Android Academy?
No prior experience is required. Android Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 2 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Taming Recomposition” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Android Academy lesson?
Yes. Every Android Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Measuring Performance
- Taming Recomposition
- Memory Leaks and Fixes
- Startup and Baseline Profiles