8 minutes
How to avoid excessive recomposition in Compose?
Android Jetpack Compose makes UI development fast, but performance can tank if you’re not mindful. Here’s a structured deep-dive into Compose performance optimization — from recompositions to layouts to runtime tricks.
Core Principle: Minimize Unnecessary Recompositions
Compose is declarative: whenever state changes, it re-runs composables. This is powerful, but can be wasteful.
Best Practices
Use remember
Store expensive objects (like Paint, CoroutineScope, MutableState) so they aren’t recreated on every recomposition.
val paint = remember { Paint() }
Use rememberSaveable
For state that must survive configuration changes (screen rotations, process death).
var text by rememberSaveable { mutableStateOf("") }
Avoid unnecessary mutableStateOf : Too many state holders = recompositions all over. Scope your state carefully.
This is about how you manage state in Jetpack Compose. Let’s break it down.
- What mutableStateOf does
val counter = mutableStateOf(0)
Wraps a value in a State object.
When you change it (counter.value++), Compose invalidates all composables that read that state. Those composables recompose to reflect the new value. So every mutableStateOf is basically a recomposition trigger.
- Why “too many state holders” is bad
If you sprinkle mutableStateOf everywhere — inside multiple composables, inside loops, etc. — you end up with:
Dozens or hundreds of independent state objects. Each change triggers recompositions in its scope, often larger than you expect.
Hard to reason about — who owns what? who’s responsible for resetting?
It’s like giving every single button, counter, or flag its own mini-brain. You get a mess of signals firing off everywhere.
- Example: ❌ Bad
@Composable
fun ShoppingCart() {
val item1Checked = remember { mutableStateOf(false) }
val item2Checked = remember { mutableStateOf(false) }
val item3Checked = remember { mutableStateOf(false) }
Column {
Checkbox(checked = item1Checked.value, onCheckedChange = { item1Checked.value = it })
Checkbox(checked = item2Checked.value, onCheckedChange = { item2Checked.value = it })
Checkbox(checked = item3Checked.value, onCheckedChange = { item3Checked.value = it })
}
}
Here we made 3 separate states for 3 checkboxes.
If you had 100 items → 100 mutableStateOf. Wasteful.
Each state causes recompositions on its own. Hard to scale.
- Example: ✅ Better (scope state carefully)
@Composable
fun ShoppingCart() {
var checkedStates by remember {
mutableStateOf(listOf(false, false, false))
}
Column {
checkedStates.forEachIndexed { index, checked ->
Checkbox(
checked = checked,
onCheckedChange = { newValue ->
checkedStates = checkedStates.toMutableList().also {
it[index] = newValue
}
}
)
}
}
}
Now only one state holder manages all checkbox states. Changing one value only updates the list and recomposes affected UI. Much easier to scale, reason about, and optimize.
- General rule of thumb
-
Don’t create mutableStateOf for every tiny piece of UI.
-
Instead, scope state at the right level:
-
Small, isolated state → inside one composable is fine.
-
Larger, shared or related state → group them together (list, data class, ViewModel).
-
Keep state hoisted (lifted up) so UI elements just display data and trigger events.
Stabilize data classes
Pass only stable/immutable objects into composables. If you pass a new instance each time, Compose thinks it changed → recomposes. Use @Immutable annotation or keep parameters primitive/immutable.
Even Better: Immutable + @Immutable
If you design your models as immutable and mark them stable, Compose can optimize even more.
import androidx.compose.runtime.Immutable
@Immutable
data class User(val id: Int, val name: String)
@Composable
fun UserScreen() {
val user = remember { User(1, "Alice") }
UserCard(user)
}
The @Immutable annotation tells Compose this class is safe:
If two instances are equal (equals() returns true), Compose can skip recomposition.
That means even if you pass a new object later but with the same values, Compose won’t redraw.
Another common pitfall: Lists
@Composable
fun UserListScreen() {
val users = listOf(User(1, "Alice"), User(2, "Bob")) // New list every recomposition
LazyColumn {
items(users) { user -> UserCard(user) }
}
}
Fix:
@Composable
fun UserListScreen() {
val users = remember {
listOf(User(1, "Alice"), User(2, "Bob"))
}
LazyColumn {
items(users, key = { it.id }) { user -> UserCard(user) }
}
}
Rule of thumb:
If the data doesn’t actually change, but your code creates a new instance each recomposition, Compose will treat it as “changed” and recompose. Use remember or @Immutable to stabilize objects.
Smart UI Structuring
Split large composables
Break into smaller ones, so recomposition is localized. A LazyColumn with 1 big item = full redraw; with small items = only changed item redraws.
derivedStateOf
Cache computed values that depend on state, so they don’t trigger recompositions every time.
val expensiveCalculation = remember {
derivedStateOf { list.filter { it.active } }
}
list.filter { it.active } This creates a new list every time it runs.
If you call it inside a composable directly, Compose will recalculate it on every recomposition, even if list hasn’t changed.
That can be expensive for large lists.
remember { … } Ensures the block inside is evaluated only once per composition (not on every recomposition).
So we don’t recreate a new derivedStateOf object each time.
derivedStateOf { … } Creates a state that is derived from other state values.
It only recalculates its value when the state(s) it reads actually change.
If list is stable and doesn’t change, the calculation won’t rerun.
Example in practice
Without derivedStateOf:
@Composable
fun MyScreen(list: List<Item>) {
// filter runs on every recomposition, even if list didn’t change
val activeItems = list.filter { it.active }
LazyColumn {
items(activeItems) { item -> Text(item.name) }
}
}
With derivedStateOf:
@Composable
fun MyScreen(list: List<Item>) {
val activeItems by remember {
derivedStateOf { list.filter { it.active } }
}
LazyColumn {
items(activeItems) { item -> Text(item.name) }
}
}
Now activeItems is recomputed only when list actually changes.
If the screen recomposes due to unrelated state (e.g., theme, other UI), the expensive .filter() won’t run again.
-
remember → keeps the derived state object alive across recompositions.
-
derivedStateOf → avoids recomputing unless dependencies change.
Together, they optimize expensive calculations in Compose by making them reactive but not wasteful.
Use key in LazyColumn: Helps Compose reuse item content instead of recreating.
items(items, key = { it.id }) { item -> … }
Example: Bad vs Good
Bad (recreates list every recomposition → whole LazyColumn redraws)
@Composable
fun UserList(users: List<User>) {
LazyColumn {
items(users) { user -> UserRow(user) }
}
}
Good
@Composable
fun UserList(users: List<User>) {
LazyColumn {
items(users, key = { it.id }) { user ->
UserRow(user)
}
}
}
Layout & Rendering
Prefer Modifier.layoutId + ConstraintLayout for complex UIs rather than deeply nested Rows/Columns.
Avoid nested modifiers that measure repeatedly (e.g., many .wrapContentSize().fillMaxSize().padding() chains).
Use LazyColumn/LazyRow instead of Column/Row with verticalScroll for large lists.
- How Column + verticalScroll works
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
items.forEach { item ->
Text(item.name)
}
}
Column lays out all children at once.
Even if the screen shows only 10 items, if your list has 1,000 items → Compose creates and measures all 1,000 composables immediately.
This means:
High memory usage (all UI objects exist in memory).
High CPU cost (all items measured and composed at once).
Long startup time before anything shows.
In short: it does eager composition.
- How LazyColumn works
LazyColumn {
items(items) { item ->
Text(item.name)
}
}
LazyColumn is like RecyclerView for Compose.
It only composes and lays out the items that are visible on screen + a small buffer.
As you scroll, off-screen items are disposed and new ones are composed.
This means:
-
Much lower memory use.
-
Faster first render (only 10–20 items instead of 1,000).
-
Smooth scrolling (recycles efficiently).
-
In short: it does lazy composition.
- Visual analogy
Column + verticalScroll → like a giant banner where you painted all 1,000 items at once, then rolled it up for scrolling. Heavy upfront.
LazyColumn → like a window that only paints what’s visible; when you scroll, it repaints just the next bit. Much lighter.
- When to use which
-
Use LazyColumn/LazyRow for large or dynamic lists (chat messages, product feeds, search results).
-
Use Column + verticalScroll only for small, finite lists (e.g., 5–10 settings toggles, a short form).
-
Don’t use Column+verticalScroll for unbounded or hundreds+ items — you’ll get jank and OOM.
Images & Graphics
Use Coil/Glide + rememberImagePainter for efficient caching.
Provide correct contentScale & size modifiers → avoid full-resolution images being scaled at runtime.
Use SubcomposeAsyncImage carefully (can be heavy, only when needed).
Runtime & Debug Tools
Enable Recomposition Counts
@Composable
fun DebugRecompose(tag: String) {
val count = remember { mutableStateOf(0) }
SideEffect { count.value++ }
Log.d("Recompose", "$tag: ${count.value}")
}
Drop DebugRecompose(“MyComposable”) inside any composable to see how often it runs.
Use Layout Inspector (Android Studio) → see recompositions and measure costs.
Trace with ComposeMetrics → adb shell setprop debug.compose.metrics true.
Architectural Choices
Unidirectional data flow (UDF): keep state at the right level, pass down immutable props.
Don’t pass State too deep unless needed — unwrap it with .value and pass the raw data.
Hoist state out of recomposing components (ViewModel / remember at screen-level).
Quick Wins
-
Wrap expensive calculations with remember or derivedStateOf.
-
Use Lazy* containers for lists.
-
Avoid rebuilding new objects in parameters (Modifier.padding(Random().nextInt()) is a perf killer).
-
Don’t overuse SubcomposeLayout unless absolutely necessary.
-
Profile! Don’t guess. Use Compose Tracing + Layout Inspector.