Go back

Reducing Unnecessary Recompositions in Jetpack Compose

Android

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).

@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 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
@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 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 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, 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 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
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

  1. Defer reads to push state changes down to the smallest possible scope, or into the layout and draw phases.
  2. Use derivedStateOf to buffer rapid state changes into distinct UI signals.
  3. 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.