androidengineers.Book a session

Jetpack Compose System Design

Recomposition & Snapshot State

article25 minHard

Compose recomposition is the mechanism that re-executes composable functions when their inputs change. Understanding how Compose decides what to recompose, and when it decides not to, is essential for writing efficient UIs.

Snapshot State: The Engine

Compose's state system is built on a snapshot system. When you write val count by remember { mutableStateOf(0) }, you're creating a State<Int> that lives inside Compose's snapshot graph.

// These are equivalent — both create snapshot-backed state
val count1 = remember { mutableStateOf(0) }
val count2 by remember { mutableStateOf(0) }  // delegate syntax

// Multiple state values — use mutableStateListOf / mutableStateMapOf
val items = remember { mutableStateListOf<String>() }
val map = remember { mutableStateMapOf<String, Int>() }

When any State read during a composable's execution changes, Compose schedules that composable for recomposition.

Reading State = Subscribing to Changes

// ✅ State read INSIDE the composable — Compose knows to recompose this scope
@Composable
fun Counter(count: State<Int>) {
    Text("Count: ${count.value}")  // reading count.value HERE = subscription
}

// ❌ State read OUTSIDE the composable — Compose can't track this
@Composable
fun Counter(countValue: Int) {  // int passed by value — no reactivity
    Text("Count: $countValue")
}

// ✅ Correct: pass State object or use ViewModel
@Composable
fun Counter(viewModel: CounterViewModel = viewModel()) {
    val count by viewModel.count.collectAsStateWithLifecycle()
    Text("Count: $count")
}

What Triggers Recomposition

  1. State read changes: mutableStateOf, mutableStateListOf, StateFlow.collectAsState()
  2. Composable inputs change: parameters that are not @Stable or primitive change value
  3. CompositionLocal changes: LocalContext, custom CompositionLocalProvider

What does NOT trigger recomposition:

  • Parameters that are equal to their previous values (for @Stable or primitive types)
  • State that is read in a lambda/modifier that Compose defers to a later phase

@Stable and @Immutable

Compose uses these annotations to skip recomposition when all parameters are equal:

// Without @Stable: Compose can't guarantee equality → recomposes every time parent does
data class ArticleState(
    val title: String,
    val body: String,
    val isBookmarked: Boolean
)

// With @Stable: Compose knows to skip if none of the visible properties changed
@Stable
data class ArticleState(
    val title: String,
    val body: String,
    val isBookmarked: Boolean
)

// @Immutable: even stronger — Compose can skip even without checking properties
// Use only when the object truly never changes after creation
@Immutable
data class Theme(val primaryColor: Color, val fontFamily: FontFamily)

Avoiding Unnecessary Recomposition

// ❌ Lambda capture forces recomposition of the parent when count changes
@Composable
fun Parent(count: Int, onClick: () -> Unit) {
    // Child recomposes because onClick is a new instance each recomposition
    Child(label = "Click me ($count)", onClick = onClick)
}

// ✅ Remember the lambda to stabilize it
@Composable
fun Parent(count: Int, onCount: () -> Unit) {
    val stableOnClick = rememberUpdatedState(onCount)
    Child(
        label = "Click me ($count)",
        onClick = { stableOnClick.value() }  // stable lambda that wraps the latest callback
    )
}

DerivedStateOf: Computed Values

Use derivedStateOf when a value is computed from other state but changes less often:

@Composable
fun ListWithScrollButton(items: List<String>) {
    val listState = rememberLazyListState()

    // ✅ derivedStateOf: only triggers recomposition when the boolean changes,
    //    not on every scroll pixel
    val showScrollToTop by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 2 }
    }

    Box {
        LazyColumn(state = listState) { /* items */ }

        if (showScrollToTop) {
            FloatingActionButton(onClick = { /* scroll to top */ }) {
                Icon(Icons.Default.ArrowUpward, "Scroll to top")
            }
        }
    }
}

Key Takeaways

ConceptRule
rememberSurvives recomposition; use for all mutable state and expensive computations
mutableStateOfCreates snapshot-backed state that triggers recomposition on write
@StableAllows Compose to skip recomposition when parameters are equal
derivedStateOfComputed values that change less frequently than their sources
Lambda stabilityUnstable lambdas force recomposition; use rememberUpdatedState or stable references
Snapshot scopeState reads inside a composable scope — not in callbacks or coroutines — create subscriptions

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Recomposition & Snapshot State | Android System Design | Android Engineers