Recomposition is the core mechanism that makes Compose reactive. Understanding what triggers it, how the stability system decides what to skip, and how to diagnose performance problems separates candidates who use Compose from those who actually understand it.
Recomposition is a phase in Jetpack Compose where the runtime re-executes composable functions when their input state changes so the UI can render the new state. Compose tracks which state values each composable reads, and when a state changes, only the composables that read that state are re-invoked. This is what makes Compose declarative â I describe the UI as a function of state, and the framework handles updating it.
Recomposition is triggered when a State object that a composable reads during composition changes its value. This includes mutableStateOf, mutableStateListOf, mutableStateMapOf, and any state derived from these like derivedStateOf. Reading the .value of a state object creates a subscription â the runtime knows exactly which composables to invalidate when that state changes.
If I change a regular variable (not wrapped in State), Compose has no way to know about it and wonât recompose.
Smart recomposition means Compose doesnât blindly re-execute everything â it only recomposes the composables whose inputs have actually changed. If a parent composable recomposes but passes the same parameters to a child, the child can be skipped entirely. The runtime compares the new arguments with the previous ones and skips the function if theyâre all equal.
This is why parameter types matter. If all parameters are stable and havenât changed, Compose skips the function. If even one parameter is unstable, Compose canât guarantee equality and must recompose.
The Compose compiler analyzes every type used as a composable parameter and classifies it as either stable or unstable. A type is considered stable if:
Int, String, Float, Boolean)@Stable or @Immutableval and are themselves stable typesUnstable types canât be reliably compared between recompositions, so Compose always recomposes when an unstable parameter is passed â even if the actual values havenât changed.
@Immutable tells the compiler that all public properties of this type will never change after construction. Once created, the object is frozen. @Stable is a weaker contract â it says the typeâs public properties might change, but when they do, Compose will be notified through the snapshot system (like MutableState).
@Immutable
data class UserProfile(
val name: String,
val avatarUrl: String,
val isVerified: Boolean
)
@Stable
class CartState(
items: List<CartItem>
) {
var items by mutableStateOf(items)
private set
}
Use @Immutable for data classes and model classes that donât change after creation. Use @Stable for state holders where properties can change but changes go through Composeâs state system.
Kotlinâs List interface doesnât guarantee immutability â a MutableList can be cast to List, so the compiler canât trust that the contents wonât change. Even if I pass a listOf(...), the declared type is still kotlin.collections.List, which the compiler marks as unstable.
The fix is to use Kotlinx Immutable Collections (ImmutableList, PersistentList) or wrap the list inside a state holder marked with @Immutable or @Stable:
// Unstable â Compose can't skip
@Composable
fun TagList(tags: List<String>) { ... }
// Stable â Compose can skip when content hasn't changed
@Composable
fun TagList(tags: ImmutableList<String>) { ... }
// Also stable â wrapper approach
@Immutable
data class TagListState(val tags: List<String>)
@Composable
fun TagList(state: TagListState) { ... }
The wrapper approach is often the simplest because it doesnât need an external dependency.
By default, the Compose compiler treats interfaces as unstable because it canât verify what the concrete implementation will be at runtime. Even if all implementations are immutable, the interface itself doesnât carry that guarantee.
Mark the interface with @Immutable to tell the compiler that all implementations will be immutable:
@Immutable
interface UiEvent
@Immutable
data class NavigateEvent(val route: String) : UiEvent
@Immutable
data class ShowSnackbar(val message: String) : UiEvent
Similarly, mark interfaces with @Stable when implementations will use Composeâs state system for mutations. Without these annotations, any composable that takes an interface parameter will never be skipped.
When I write { viewModel.onClick() }, a new lambda instance is created on every recomposition. The new instance is not equal to the previous one, so Compose treats the parameter as changed and recomposes the child composable.
Using a method reference like viewModel::onClick produces a stable reference that remains the same across recompositions. Compose sees the same function reference and can skip the child composable if nothing else changed. This matters most in lists or frequently recomposing UI where many lambda allocations add up.
For stable types, Compose uses equals() to compare old and new parameter values. If all parameters are stable and equals() returns true for all of them, the composable is skipped entirely.
For unstable types, Compose doesnât attempt comparison â it always recomposes. This is why marking data classes with @Immutable matters. Without it, a data class that uses a List parameter is unstable, and every recomposition of the parent forces the child to recompose too, even if the actual data hasnât changed.
For lambda parameters, the comparison depends on whether the lambda captures any values. A non-capturing lambda is a singleton and always equal to itself. A capturing lambda is wrapped in a remember block by the compiler if strong skipping mode is enabled â otherwise each recomposition creates a new instance.
Positional memoization is how Compose identifies composable calls â by their position in the source code, not by any key or name. The compiler assigns a unique key to each call site based on its position in the code, and the runtime uses that key to match composable calls between compositions.
This is why calling composables inside if/else or loops needs care. If a composable moves to a different position in the call tree (like items reordering in a loop), Compose treats it as a new composable and throws away the old state. The key() composable exists to override positional identity when ordering can change.
Donut-hole skipping means Compose can skip recomposing a parent composable while still recomposing the content lambda inside it. The âdonutâ is the parent and the âholeâ is the content.
@Composable
fun Card(content: @Composable () -> Unit) {
Surface(modifier = Modifier.padding(16.dp)) {
content() // This lambda can recompose independently
}
}
@Composable
fun Screen() {
val count by viewModel.count.collectAsStateWithLifecycle()
Card {
Text("Count: $count") // Only this recomposes when count changes
}
}
Card itself doesnât recompose because its parameters havenât changed. But the content lambda captures count, so when count changes, only the lambda body re-executes. This is a natural result of how Compose tracks state reads â it scopes invalidation to the smallest composable that reads the changed state.
Strong skipping mode is a compiler feature (enabled by default since Compose Compiler 2.0) that changes how Compose handles unstable parameters and lambdas. Without strong skipping, a composable with any unstable parameter is never skipped. With strong skipping, Compose uses instance equality (===) for unstable parameters instead of giving up entirely.
The other major change is lambda memoization. Without strong skipping, capturing lambdas create a new instance on every recomposition. With strong skipping, the compiler wraps them in remember automatically, so the lambda instance is reused as long as its captured values havenât changed.
// Without strong skipping: this lambda recreates every recomposition
Button(onClick = { viewModel.submit(formData) })
// With strong skipping: compiler generates something like
Button(onClick = remember(formData) { { viewModel.submit(formData) } })
Strong skipping makes most manual optimizations around lambda stability unnecessary. But it doesnât eliminate the need for @Immutable and @Stable â structural equality (equals()) is still better than instance equality (===) for data classes.
The most common ones:
@Immutable or @Stable annotations on data classes and state holders. Donât pass raw List â use ImmutableList or a stable wrapper.viewModel::onClick) or hoist the lambda to a remember block. Strong skipping mode helps here, but method references are still cleaner.key = { item.id }.Modifier.graphicsLayer { alpha = animatedAlpha.value } instead of reading the animated value during composition. Lambda-based modifiers defer the state read to a later phase.The runtime doesnât recompose immediately when a state changes. It invalidates the affected scope and schedules recomposition for the next frame using Choreographer.postFrameCallback. Multiple state changes within the same frame are batched into a single recomposition pass.
The recomposition process runs on the main thread during the composition phase. The runtime walks the slot table, re-executes invalidated composables, and records the differences. Then the layout phase measures and positions nodes, and finally the drawing phase renders pixels. These are the three phases of Compose: Composition, Layout, and Drawing.
If state changes only affect the drawing phase (like a color or alpha change via Modifier.graphicsLayer), Compose can skip composition and layout entirely and just redraw. This is why reading state inside graphicsLayer or drawBehind lambdas is more efficient for animations.
Modifier.graphicsLayer creates a separate render layer for the composable. Changes inside the lambda only trigger the draw phase â they skip composition and layout entirely. This makes it ideal for animations.
val alpha by animateFloatAsState(targetValue = if (visible) 1f else 0f)
// Bad â reads alpha during composition, triggers full recomposition
Box(modifier = Modifier.alpha(alpha))
// Good â reads alpha only in draw phase, skips composition and layout
Box(modifier = Modifier.graphicsLayer { this.alpha = alpha })
The key difference is between the lambda version Modifier.graphicsLayer { } and the direct parameter version Modifier.graphicsLayer(alpha = 0.5f). The lambda version defers the state read to the draw phase. The direct version reads during composition, so it triggers recomposition when the value changes. Always use the lambda version when the value is animated or changes frequently.
The slot table is Composeâs internal data structure â a flat array (gap buffer) that stores the composable tree. During composition, every composable call writes its state, parameters, and child information into the slot table. The runtime walks this table during recomposition to compare old values with new ones and decide what to skip.
The gap buffer design allows efficient insertions and deletions at the current position without reallocating the entire array. When a composable is added or removed, the runtime moves the gap to that position and inserts or removes slots. The Applier then maps changes from the slot table to the actual UI tree (the LayoutNode tree for Compose UI).
The Compose compiler can generate stability reports that show exactly how each class is classified and which composables are restartable and skippable. Enable it in the build config:
// build.gradle.kts
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_reports")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
The report generates three files per module. The *-classes.txt file shows each class with its stability â stable, unstable, or runtime â and flags which fields are causing instability. The *-composables.txt shows each composable function with whether itâs restartable, skippable, and which parameters are stable or unstable.
Look for composables marked as restartable but not skippable â those are the ones that recompose even when their inputs havenât changed. Trace the unstable parameter back to the field causing it and fix it with @Immutable, @Stable, or by switching to immutable collections.
Layout Inspector in Android Studio shows recomposition counts directly on the composable tree. Enable âShow Recomposition Countsâ in the Layout Inspector toolbar and interact with the app â composables that recompose frequently show high counts and are highlighted.
I can also add recomposition tracking in code during development:
@Composable
fun ProductCard(product: Product, onClick: () -> Unit) {
SideEffect {
Log.d("Recomposition", "ProductCard recomposed: ${product.id}")
}
// actual UI
}
SideEffect runs after every successful composition, so the log tells me exactly when and how often a composable recomposes. High recomposition counts in scrolling lists or animations are the first place to look for jank.
The stability configuration file lets me declare classes as stable without modifying their source code. This is critical for classes from external libraries â I canât add @Stable or @Immutable to classes I donât own, but I can list them in the config file.
Create a file like compose_stability.conf:
// Treat all classes in these packages as stable
java.time.*
kotlinx.datetime.*
com.google.android.gms.maps.model.LatLng
Then reference it in the build file:
composeCompiler {
stabilityConfigurationFile =
project.layout.projectDirectory.file("compose_stability.conf")
}
The compiler treats listed classes as stable during recomposition analysis, enabling skipping for composables that use them. Without this, any composable accepting a LocalDateTime parameter would always recompose because the compiler canât verify that java.time classes are truly immutable. The config file is especially valuable for apps using java.time, Google Maps models, or Protocol Buffer generated classes.
movableContentOf wraps a composable so its content can be moved to a different position in the composition tree without losing its state or triggering disposal and re-creation. Normally, if a composable moves from one branch of an if/else to another, Compose treats it as a new composable â all its remember state is lost and effects are restarted.
val content = remember {
movableContentOf {
ExpensiveChart(data = chartData)
}
}
if (isFullScreen) {
FullScreenContainer { content() }
} else {
CompactContainer { content() }
}
Without movableContentOf, switching between full screen and compact would destroy the chart and recreate it from scratch. With it, the chartâs state, animations, and internal remember values survive the move. This is useful for shared element transitions and adaptive layouts where content physically moves between containers.
derivedStateOf creates a state object that only updates when its computed result actually changes. If I have a state that changes frequently but only a derived value matters to the UI, derivedStateOf filters out the noise.
val searchQuery = mutableStateOf("")
val filteredItems = derivedStateOf {
items.filter { it.name.contains(searchQuery.value, ignoreCase = true) }
}
Without derivedStateOf, any composable reading searchQuery would recompose on every keystroke. With it, composables reading filteredItems only recompose when the filtered list actually changes. This is especially useful for search, filtering, and any case where the raw state changes more often than the derived result.
LazyColumn handle recomposition differently from Column with a forEach?remember and rememberSaveable in terms of recomposition?Modifier.graphicsLayer { } (lambda version) and Modifier.graphicsLayer(alpha = 0.5f) (direct version) in terms of recomposition?