Reducing Unnecessary Recompositions in Jetpack Compose
Reduce unnecessary Jetpack Compose recompositions by deferring state reads, using derivedStateOf, and keeping parameters stable.
Jetpack Compose is designed to be efficient, but it relies heavily on its ability to “skip” recomposition for parts of the UI that have not 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 three practical techniques to eliminate unnecessary recompositions and ensure your Compose app runs smoothly.
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 have not changed, but even then, the parent logic runs).
@Composablefun 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.
@Composablefun 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 does not need to recompose when scrollOffset changes; only the layout logic re-runs.
Another Example: Background Animations
When animating colors, it is common to accidentally trigger recompositions on every frame.
// BAD: Recomposes purely to change a draw property@Composablefun AnimatedBox(color: Color) { Box(Modifier.background(color))}
// GOOD: Defer read to the draw phase@Composablefun 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 whether a header is 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 is greater than zero, the scope still observes the change.
val listState = rememberLazyListState()
// BAD: Recomposes on EVERY scroll frameval 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 typedval 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, such as ArrayList. Therefore, Compose treats all standard List types as unstable.
The Problem
If you pass a List<Article> to an ArticleList composable, ArticleList will recompose every time its parent recomposes, even if the list content is identical.
@Composable// BAD: List<Article> is unstablefun 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 stablefun 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.
@Immutabledata class ArticleState( val articles: List<Article>)
@Composable// GOOD: ArticleState is explicitly marked immutablefun 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 var isLoading: Boolean)
// GOOD: Use val for immutability val isLoading: Boolean)Bonus: Enable Compose Compiler Metrics
How do you know if your classes are unstable or if your composables are skippable? You do not 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 will 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 and 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 will stop fighting the framework and start letting Compose do what it does best: optimize your UI for you.