Jetpack Compose is designed to be efficient, but it relies heavily on its ability to “skip” recomposition for parts of the UI that haven’t changed. When Compose cannot skip essentially static content, your app does more work than necessary, leading to dropped frames, battery drain, and UI jank.
Here are 3 practical techniques to eliminate unnecessary recompositions and ensure your Compose app runs butter-smooth.
1. Defer State Reads as Long as Possible
One of the most common causes of over-recomposition is reading state higher up in the hierarchy than needed. If a parent composable reads a state value just to pass it to a child, the parent will recompose every time that state changes.
Instead, defer the read until the actual point of usage, often by passing a lambda.
The Problem
In this example, Parent reads scrollOffset. Every time scrollOffset changes, Parent recomposes, which forces HeavyChild to recompose (unless HeavyChild is skippable and inputs haven’t changed—but even then, the parent logic runs).
@Composable
fun Parent(scrollOffset: Int) {
// BAD: Parent reads the value here
Box(Modifier.offset(y = scrollOffset.dp)) {
HeavyChild()
}
}
The Solution
Use the lambda version of modifiers or pass a lambda to the child. For layout offsets, Modifier.offset has a lambda overload that defers the read to the layout phase, avoiding the composition phase entirely for the parent.
@Composable
fun Parent(scrollProvider: () -> Int) {
// GOOD: State is read inside the lambda, strictly during layout/draw
Box(Modifier.offset { IntOffset(0, scrollProvider()) }) {
HeavyChild()
}
}
By deferring the read, the Parent composable doesn’t need to recompose when scrollOffset changes; only the layout logic re-runs.
Another Example: Background Animations
When animating colors, it’s common to accidentally trigger recompositions on every frame.
// BAD: Recomposes purely to change a draw property
@Composable
fun AnimatedBox(color: Color) {
Box(Modifier.background(color))
}
// GOOD: Defer read to the draw phase
@Composable
fun AnimatedBox(colorProvider: () -> Color) {
Box(Modifier.drawBehind {
drawRect(colorProvider())
})
}
2. Master derivedStateOf for Frequent Updates
Sometimes you have a state that changes extremely rapidly (like a scroll position), but your UI only cares about a specific condition (like “is the header visible?”). If you rely directly on the raw state, your UI will recompose on every single pixel of scroll.
derivedStateOf allows you to create a new state object that only updates when the result of a calculation changes.
The Problem
Here, showButton is recalculated every time listState.firstVisibleItemIndex changes. Even if the index goes from 10 to 11 (and we only care if it’s > 0), the scope still observes the change.
val listState = rememberLazyListState()
// BAD: Recomposes on EVERY scroll frame
val showButton = listState.firstVisibleItemIndex > 0
if (showButton) {
ScrollToTopButton()
}
The Solution
Wrap the calculation in derivedStateOf. The showButton state object will now only emit a new value when the boolean result actually toggles between true and false.
val listState = rememberLazyListState()
// GOOD: Recomposes ONLY when the condition flips (true <-> false)
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
if (showButton) {
ScrollToTopButton()
}
Rule of thumb: Use derivedStateOf when your input changes more often than you want your output to update.
Another Example: Form Validation
Imagine a form where you only want to enable the “Submit” button when all fields are valid. If you check validation on every keystroke in the UI, it can be wasteful.
val username = remember { mutableStateOf("") }
val email = remember { mutableStateOf("") }
// GOOD: Only updates when the VALIDITY status changes, not on every character typed
val isFormValid by remember {
derivedStateOf { username.value.isNotBlank() && email.value.contains("@") }
}
Button(enabled = isFormValid, onClick = { /*...*/ }) {
Text("Submit")
}
3. Ensure Stability (The “Unstable List” Trap)
Compose uses a concept called “Stability” to determine if a Composable can be skipped. If Compose thinks a parameter is “unstable” (meaning it might have changed implicitly), it will force a recomposition to be safe.
The most common trap? The standard List<T>. Even if you use val list: List<String>, the underlying implementation is technically mutable (e.g., ArrayList). Therefore, Compose treats all standard List types as unstable.
The Problem
If you pass a List<Article> to a ArticleList composable, ArticleList will recompose every time its parent recomposes, even if the list content is identical.
@Composable
// BAD: List<Article> is unstable
fun ArticleList(articles: List<Article>) { ... }
The Solution
You have two main options:
Option A: Use Kotlinx Immutable Collections This is the recommended approach. These collections are guaranteed to be immutable, so Compose treats them as stable.
// In your dependencies:
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
@Composable
// GOOD: ImmutableList is stable
fun ArticleList(articles: ImmutableList<Article>) { ... }
Option B: Annotate with @Immutable or @Stable If you own the class, you can annotate it. Alternatively, wrap the list in a stable wrapper class.
@Immutable
data class ArticleState(
val articles: List<Article>
)
@Composable
// GOOD: ArticleState is explicitly marked immutable
fun ArticleList(state: ArticleState) { ... }
Another Example: The var Trap
Data classes are not automatically stable if they contain mutable properties (var).
// BAD: This is unstable because 'isLoading' can change outside Compose's knowledge
data class UiState(
var isLoading: Boolean
)
// GOOD: Use val for immutability
data class UiState(
val isLoading: Boolean
)
Bonus: Enable Compose Compiler Metrics
How do you know if your classes are unstable or if your composables are skippable? You don’t have to guess.
The Compose Compiler can generate detailed reports about the stability of your classes and composables.
To enable it, add the following to your build.gradle.kts (or a dedicated compose-compiler-config.conf file):
android {
composeOptions {
kotlinCompilerExtensionVersion = "..."
}
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
kotlinOptions {
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
project.buildDir.absolutePath + "/compose_metrics"
)
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
project.buildDir.absolutePath + "/compose_metrics"
)
}
}
After a build, check app/build/compose_metrics. You’ll find files like app_release-composables.txt. Look for:
restartable skippable fun MyComposable(...)
If you see restartable but NOT skippable, that means your composable re-runs every time the parent does, usually because of an unstable parameter.
Summary
- Defer reads to push state changes down to the smallest possible scope (or into the layout/draw phases).
- Use
derivedStateOfto buffer rapid state changes into distinct UI signals. - Fix unstable parameters (especially
Lists) using immutable collections or annotations to enable skipping.
By applying these three patterns and validating them with compiler metrics, you’ll stop fighting the framework and start letting Compose do what it does best: optimize your UI for you.
Photo by Matt Hardy on Unsplash