You call mutableStateOf("hello"), read it in a composable, change the value, and the UI updates automatically. But how? How does Compose know which composable read which state? How does it know to recompose only that composable and not the entire screen? The answer is the Snapshot system — Compose’s internal state observation engine. Understanding it turns “Compose magic” into predictable, debuggable behavior. This guide explains the internals that make Compose reactive.
The Mental Model — How State Observation Works
// Think of it like a SECURITY CAMERA SYSTEM in a museum:
//
// Each painting (State) has a camera pointed at it
// Each visitor (Composable) wears a badge
//
// When a visitor LOOKS at a painting → the camera records:
// "Visitor ArticleCard looked at state.title"
//
// When someone CHANGES a painting → security checks the recordings:
// "Who looked at this painting? ArticleCard did!"
// → ONLY ArticleCard is notified (recomposed)
// → Other visitors who never looked at it → NOT disturbed
//
// In Compose terms:
// mutableStateOf() = the painting with a camera
// Reading state.value = looking at the painting (recorded)
// Writing state.value = changing the painting (triggers notification)
// Recomposition = notifying the composables that were recorded looking
//
// This is WHY Compose can recompose ONLY the composables that read changed state
// It's not scanning the whole tree — it knows EXACTLY who read what
What mutableStateOf Actually Creates
// When you call:
var name by mutableStateOf("Alice")
// mutableStateOf() is a TOP-LEVEL FUNCTION from compose.runtime
// It returns a MutableState<T> object
// But what IS MutableState internally?
// MutableState is an INTERFACE from compose.runtime:
interface MutableState<T> : State<T> {
override var value: T
}
interface State<out T> {
val value: T
}
// The ACTUAL implementation is SnapshotMutableStateImpl (internal class):
internal class SnapshotMutableStateImpl<T>(
value: T,
val policy: SnapshotMutationPolicy<T>
) : StateObject, MutableState<T> {
// StateObject is an INTERNAL INTERFACE — the Snapshot system uses this
// to track reads and writes
// When .value is READ:
override var value: T
get() {
// 1. Record that the current Snapshot reader accessed this state
// The Snapshot system notes: "Composable X read this StateObject"
// This is the READ OBSERVATION
val snapshot = Snapshot.current
snapshot.readObserver?.invoke(this)
// readObserver is called → Compose records the read
return /* the current value */
}
set(newValue) {
// 2. Check if the value actually CHANGED (using the policy)
if (!policy.equivalent(currentValue, newValue)) {
// 3. Record that this state was WRITTEN
val snapshot = Snapshot.current
snapshot.writeObserver?.invoke(this)
// writeObserver is called → Compose knows this state changed
// 4. Store the new value
currentValue = newValue
}
// If policy.equivalent() returns true → value didn't change
// → NO write recorded → NO recomposition triggered
}
}
// The KEY INSIGHT:
// Every get() call records WHO is reading
// Every set() call records WHAT changed
// Compose matches readers to writers → only affected composables recompose
The Snapshot System — The Engine Behind Everything
// Snapshot is a CLASS from compose.runtime
// It's Compose's version of a DATABASE TRANSACTION — but for state
// Think of Snapshots like TRANSACTIONS:
// A Snapshot captures a CONSISTENT VIEW of all state at a point in time
// Changes made in one Snapshot don't affect other Snapshots until committed
// THREE types of Snapshots:
//
// 1. GLOBAL SNAPSHOT — the "current" state of everything
// When you're NOT in a special snapshot, you read/write the global state
//
// 2. READ-ONLY SNAPSHOT — a frozen view of state
// Used during composition (recomposition)
// The composable sees a CONSISTENT view of all state
// Even if state changes mid-composition, the composable sees the old values
// This prevents "tearing" — half-old, half-new state
//
// 3. MUTABLE SNAPSHOT — allows reads AND writes
// Used for state changes that need to be atomic
// Changes are isolated until the snapshot is APPLIED (committed)
// HOW IT WORKS during recomposition:
//
// State: name = "Alice", count = 5
//
// ┌─── Recomposition starts ────────────────────────────────┐
// │ Compose takes a READ-ONLY SNAPSHOT │
// │ Snapshot sees: name = "Alice", count = 5 │
// │ │
// │ Meanwhile, on another thread: │
// │ name.value = "Bob" ← happens during recomposition │
// │ │
// │ But the Snapshot still sees: name = "Alice" │
// │ Composables get CONSISTENT data — no half-updates │
// │ │
// │ Recomposition finishes with consistent "Alice" view │
// └──────────────────────────────────────────────────────────┘
//
// After recomposition:
// Global state has: name = "Bob"
// → Another recomposition is scheduled (because name changed)
// → Next recomposition sees: name = "Bob"
//
// This is WHY Compose UI is always CONSISTENT — Snapshots guarantee it
Read observation — how Compose tracks who reads what
// When Compose starts recomposing a composable function:
//
// 1. Compose creates a ReadObserver for that composable:
// "Record every State object that this composable reads"
//
// 2. The composable function runs:
// @Composable fun Greeting(user: User) {
// Text(user.name) // reads nothing (user is a parameter, not State)
// Text("Count: ${count}") // reads count → ReadObserver records: Greeting reads count
// }
//
// 3. ReadObserver now knows: Greeting reads [count]
//
// 4. When count.value changes:
// Compose checks: "Who reads count?" → Greeting!
// → Greeting is marked for RECOMPOSITION
// → Other composables that DON'T read count → UNTOUCHED
// This is READ OBSERVATION in action:
//
// composable A reads: [stateX, stateY]
// composable B reads: [stateY, stateZ]
// composable C reads: [stateZ]
//
// stateY changes → who reads stateY? → A and B
// → A and B recompose
// → C is SKIPPED (doesn't read stateY)
//
// Compose doesn't re-run the whole tree — it knows EXACTLY which composables
// are affected by WHICH state changes
Write observation — how Compose detects changes
// When state.value is written:
//
// 1. The Snapshot system's writeObserver is called
// "State object X was modified"
//
// 2. Compose's Recomposer receives the notification
// Recomposer is a CLASS from compose.runtime — the recomposition scheduler
//
// 3. Recomposer checks: which composables read state X?
// (using the read observation records from step 2 above)
//
// 4. Those composables are marked as INVALID (need recomposition)
//
// 5. Recomposer schedules a recomposition on the next frame
// Multiple state changes in the same frame → ONE recomposition
// (batched — efficient!)
//
// 6. On the next frame:
// Recomposer runs ONLY the invalid composables
// Each composable gets a fresh Snapshot (read-only, consistent)
// New read observations are recorded (in case reads changed)
// BATCHING example:
_uiState.update { it.copy(isLoading = false) } // write 1
_uiState.update { it.copy(articles = newArticles) } // write 2
_uiState.update { it.copy(error = null) } // write 3
// Three writes in the same frame → ONE recomposition (not three!)
// Compose batches invalidation → recomposes with the final state
SnapshotMutationPolicy — When Is a Change Really a Change?
When you set state.value = newValue, Compose needs to decide: did the value actually change? The SnapshotMutationPolicy makes this decision:
// SnapshotMutationPolicy is an INTERFACE from compose.runtime
interface SnapshotMutationPolicy<T> {
fun equivalent(a: T, b: T): Boolean
// Returns true if a and b should be considered "the same"
// If equivalent → NO state change → NO recomposition
// If NOT equivalent → state changed → recomposition triggered
}
// THREE built-in policies:
// 1. structuralEqualityPolicy() — the DEFAULT
val name = mutableStateOf("Alice") // uses structuralEqualityPolicy by default
// structuralEqualityPolicy() is a TOP-LEVEL FUNCTION from compose.runtime
// Uses == (equals) to compare old and new values
// "Alice" == "Alice" → equivalent → no recomposition
// "Alice" == "Bob" → NOT equivalent → recomposition
//
// This is what you want for MOST cases:
// data class with same properties → equal → skip
// different values → not equal → recompose
//
// For data classes: uses the generated equals() (compares all properties)
// For primitives: uses value equality (5 == 5 → skip)
// 2. referentialEqualityPolicy() — compare by REFERENCE (===)
val items = mutableStateOf(listOf("a", "b"), referentialEqualityPolicy())
// referentialEqualityPolicy() is a TOP-LEVEL FUNCTION from compose.runtime
// Uses === (reference identity) to compare
// Same object reference → equivalent → no recomposition
// Different object (even with same contents) → NOT equivalent → recomposition
//
// Example:
val list1 = listOf("a", "b")
val list2 = listOf("a", "b")
// structuralEquality: list1 == list2 → true (same contents) → skip
// referentialEquality: list1 === list2 → false (different objects) → recompose!
//
// Use when: you want to recompose on ANY new object, even if contents are the same
// Rarely needed — structural equality is almost always better
// 3. neverEqualPolicy() — ALWAYS triggers recomposition
val forceUpdate = mutableStateOf(Unit, neverEqualPolicy())
// neverEqualPolicy() is a TOP-LEVEL FUNCTION from compose.runtime
// equivalent() always returns false → every write triggers recomposition
// Even: state.value = state.value → recomposes!
//
// Use when: you want to force recomposition (rare)
// Example: force refresh a composable by toggling a state
// USING a policy:
var count by mutableStateOf(0) // structural (default)
var items by mutableStateOf(listOf<Item>(), referentialEqualityPolicy())
var trigger by mutableStateOf(Unit, neverEqualPolicy())
Why the policy matters for performance
// The policy directly affects HOW MANY recompositions happen:
//
// SCENARIO: ViewModel emits the same state twice
//
// _uiState.value = UiState.Success(articles) // first time
// _uiState.value = UiState.Success(articles) // same data again
//
// With structuralEqualityPolicy (default for StateFlow → State via collectAsState):
// Success(articles) == Success(articles) → true (data class equals)
// → NO recomposition → performance win!
//
// With referentialEqualityPolicy:
// first Success !== second Success → false (different objects)
// → RECOMPOSITION even though data is the same → wasted work!
//
// With neverEqualPolicy:
// ALWAYS recomposes → maximum wasted work
//
// RULE: use the default (structural) unless you have a specific reason not to
// WHERE StateFlow fits in:
// StateFlow.value setter already checks equality (conflation)
// StateFlow with same value → doesn't emit → collectAsState doesn't trigger
// This is DOUBLE protection: StateFlow conflation + Compose mutation policy
// Both must agree "it changed" for recomposition to happen
The Recomposer — The Conductor
// Recomposer is a CLASS from compose.runtime
// It's the CONDUCTOR that orchestrates recomposition
// Think of it as: the event loop for Compose UI updates
// What the Recomposer does:
//
// 1. RECEIVES invalidation notifications
// "State X changed, composables A and B read it"
//
// 2. BATCHES invalidations
// Multiple state changes in one frame → collected together
//
// 3. SCHEDULES recomposition
// Uses the Android Choreographer to align with vsync (60fps frame boundary)
// choreographer.postFrameCallback { runRecomposition() }
//
// 4. RUNS recomposition
// For each invalid composable:
// a. Takes a read-only Snapshot (consistent state view)
// b. Calls the composable function
// c. Records new read observations
// d. Compares old vs new UI tree (diffing)
// e. Applies changes to the actual UI
//
// 5. APPLIES Snapshot
// After recomposition, state changes from mutableStateOf() writes
// that happened during recomposition are applied to the global state
// The Recomposer runs on the MAIN THREAD (Dispatchers.Main)
// This is WHY you can safely update UI in composable functions
// And WHY heavy computation in composables blocks the frame
// Recomposer lifecycle:
// Created when setContent { } is called (in Activity or ComposeView)
// Lives as long as the Composition exists
// Destroyed when the window is detached or Activity is destroyed
// You rarely interact with Recomposer directly, but you can:
val recomposer = currentRecomposer // get current recomposer (in tests)
// currentRecomposer is a COMPOSITION LOCAL
How It All Connects — Complete Flow
// Let's trace a COMPLETE state change from write to screen update:
//
// ═══ STEP 1: STATE WRITE ═════════════════════════════════════════════
// User taps "Increment" button
// onClick: count.value = count.value + 1
//
// Inside mutableStateOf's setter:
// policy.equivalent(5, 6) → false (different values)
// → Write the new value (6)
// → Call writeObserver: "count StateObject was modified"
//
// ═══ STEP 2: INVALIDATION ════════════════════════════════════════════
// Snapshot system notifies the Recomposer:
// "count was written"
//
// Recomposer checks read observations:
// "Who reads count?" → CountDisplay composable
// → Mark CountDisplay as INVALID
//
// ═══ STEP 3: SCHEDULING ══════════════════════════════════════════════
// Recomposer posts a frame callback to Choreographer:
// "On the next vsync, recompose the invalid composables"
//
// If more state changes happen BEFORE vsync:
// → They're batched into the SAME recomposition frame
// → count = 6, then count = 7 → only recomposes ONCE with count = 7
//
// ═══ STEP 4: RECOMPOSITION ═══════════════════════════════════════════
// Vsync arrives → Choreographer calls Recomposer
//
// Recomposer takes a READ-ONLY Snapshot:
// → All composables see consistent state (count = 7)
//
// Only CountDisplay is recomposed:
// @Composable fun CountDisplay(count: Int) {
// Text("Count: $count") // now shows "Count: 7"
// }
//
// Read observations are UPDATED:
// CountDisplay still reads count → recorded again
//
// Other composables (Header, Footer, etc.) → SKIPPED
//
// ═══ STEP 5: UI UPDATE ═══════════════════════════════════════════════
// Compose compares old UI tree with new:
// Text("Count: 5") → Text("Count: 7")
// Only this Text node changed → only this View is redrawn
//
// Layout phase: Text is re-measured (if size changed)
// Drawing phase: Text is re-drawn with "Count: 7"
//
// ═══ RESULT ══════════════════════════════════════════════════════════
// Screen shows "Count: 7"
// Total work: one composable re-called, one Text redrawn
// Everything else: untouched
// Time: ~1-5ms (one frame)
derivedStateOf — How It Uses Snapshots
// derivedStateOf creates a State that DERIVES from other states
// It uses the Snapshot system to be efficient:
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
// derivedStateOf is a TOP-LEVEL FUNCTION from compose.runtime
}
// HOW IT WORKS INTERNALLY:
//
// 1. derivedStateOf runs the lambda ONCE initially
// Records all states READ inside: [firstVisibleItemIndex]
// Result: firstVisibleItemIndex = 0 → showButton = false
//
// 2. User scrolls → firstVisibleItemIndex changes (0 → 1 → 2 → 3...)
// derivedStateOf is notified: "a source state changed"
//
// 3. derivedStateOf RE-RUNS the lambda:
// firstVisibleItemIndex = 1 → 1 > 0 → true
// Previous result was: false
// New result: true → DIFFERENT → notify readers of showButton
//
// 4. Composable reads showButton → recomposes (false → true)
//
// 5. User scrolls more → firstVisibleItemIndex = 2, 3, 4...
// derivedStateOf re-runs: 2 > 0 → true (same as before!)
// Previous result: true, new result: true → SAME
// → Does NOT notify readers → NO recomposition!
//
// FREQUENCY REDUCTION:
// firstVisibleItemIndex changes: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 (10 changes)
// showButton changes: false, true (2 changes)
// Recompositions: 2 instead of 10!
//
// derivedStateOf is efficient because:
// - It tracks its own source states via Snapshot read observation
// - It only notifies downstream when its RESULT changes
// - It applies the mutation policy (structural equality by default)
snapshotFlow — Bridge to the Flow World
// snapshotFlow converts Compose State reads into a cold Flow
// snapshotFlow is a TOP-LEVEL FUNCTION from compose.runtime
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { index -> analytics.logScroll(index) }
}
// HOW IT WORKS INTERNALLY:
//
// 1. snapshotFlow takes a READ-ONLY Snapshot
// Runs your lambda inside it
// Records which states were read: [firstVisibleItemIndex]
// Emits the result to the Flow
//
// 2. Registers a Snapshot apply observer:
// "Notify me when any of the recorded states change"
//
// 3. When firstVisibleItemIndex changes:
// → Snapshot observer fires
// → snapshotFlow re-runs the lambda in a new Snapshot
// → If result is different → emits to the Flow
// → If result is same → skips (built-in distinctUntilChanged)
//
// 4. Flow collector receives the new value
// In this case: analytics.logScroll(newIndex)
//
// snapshotFlow is the bridge between Compose's Snapshot world and coroutines' Flow world
// It lets you use Flow operators (debounce, filter, map) on Compose state
Thread Safety — Snapshots and Concurrency
// One of the Snapshot system's most important properties: THREAD SAFETY
//
// Multiple threads can read state SIMULTANEOUSLY — no locks needed
// Each thread sees a CONSISTENT Snapshot (read isolation)
//
// Writes are also safe:
// mutableStateOf().value = newValue is thread-safe
// The Snapshot system handles synchronization internally
//
// HOW:
// Each thread operates on its own Snapshot
// Writes are isolated until the Snapshot is applied (committed)
// If two Snapshots try to write the same state → conflict resolution:
// - Last writer wins (for global Snapshot)
// - Merge policy applied (for custom Snapshots)
//
// This is WHY you can safely:
// - Update state from viewModelScope (background thread)
// - Read state in composables (main thread)
// - Multiple composables reading the same state simultaneously
// All without explicit synchronization!
// Compare to StateFlow:
// StateFlow.value = newValue → also thread-safe (atomic)
// collectAsStateWithLifecycle → converts StateFlow to Compose State
// The Compose State then participates in the Snapshot system
// Double safety: StateFlow's atomicity + Snapshot's isolation
Common Mistakes to Avoid
Mistake 1: Not understanding when reads are tracked
// ❌ Reading state OUTSIDE a Snapshot scope — read not tracked
val currentValue = myState.value // read in a regular function
// No read observer active → Compose doesn't know this function reads myState
// If myState changes → this code is NOT re-run
// ✅ Reads are tracked INSIDE composable functions
@Composable
fun Display() {
Text("${myState.value}") // read tracked! Compose knows Display reads myState
// myState changes → Display recomposes
}
// ✅ Also tracked inside: derivedStateOf, snapshotFlow
val derived = derivedStateOf { myState.value > 0 } // tracked
snapshotFlow { myState.value } // tracked
Mistake 2: Thinking structural equality means no recomposition for same-content lists
// ❌ Expecting same-content new list to skip recomposition
var items by mutableStateOf(listOf(1, 2, 3))
items = listOf(1, 2, 3) // new List object with same contents
// structuralEqualityPolicy: listOf(1,2,3) == listOf(1,2,3) → TRUE → no recompose ✅
// This DOES skip correctly for simple lists!
// BUT: if your list contains mutable objects or complex types
// where equals() isn't properly implemented → may not skip as expected
// Always use data classes with proper equals() for state objects
Mistake 3: Using neverEqualPolicy without understanding the cost
// ❌ neverEqualPolicy on frequently-changed state — excessive recompositions
var scrollOffset by mutableStateOf(0f, neverEqualPolicy())
// Every scroll pixel → recomposition → even when value is the same!
// ✅ Use structural equality (default) — skips when unchanged
var scrollOffset by mutableStateOf(0f) // 0.0f == 0.0f → skip
// Or better: read scroll state in Layout/Drawing phase (see Compose Performance blog)
Mistake 4: Reading state in the wrong phase
// ❌ Reading rapidly-changing state in Composition phase
@Composable
fun Header(scrollState: ScrollState) {
val alpha = scrollState.value / 500f // READ in Composition
Text(modifier = Modifier.alpha(alpha))
// scrollState.value changes every frame → recomposes every frame!
// Snapshot records: Header reads scrollState → invalidated every frame
}
// ✅ Read in Drawing phase — skips Composition entirely
@Composable
fun Header(scrollState: ScrollState) {
Text(modifier = Modifier.graphicsLayer {
alpha = scrollState.value / 500f // READ in Drawing phase
})
// graphicsLayer lambda runs during Drawing, not Composition
// No Composition read recorded → Header doesn't recompose on scroll
// Only the drawing changes — much cheaper!
}
Mistake 5: Forgetting that Snapshot isolation can delay visibility
// ❌ Expecting a state change to be immediately visible in the same frame
state.value = "new"
println(state.value) // "new" ← visible immediately (same thread, global Snapshot)
// But DURING RECOMPOSITION:
@Composable
fun Screen() {
val value = state.value // reads from the recomposition Snapshot
Button(onClick = {
state.value = "changed" // writes to global Snapshot
// The composable is still running with the OLD Snapshot
// "changed" won't be visible until NEXT recomposition
}) {
Text(value) // still shows old value in this recomposition
}
}
// This is BY DESIGN — Snapshots ensure consistent views
// The change triggers a NEW recomposition where "changed" is visible
Summary
- The Snapshot system is Compose’s internal engine for tracking state reads and writes — it’s how Compose knows which composables to recompose
mutableStateOf()creates a SnapshotMutableStateImpl that records every read (via readObserver) and every write (via writeObserver)- Read observation: during recomposition, Compose records which composable reads which State objects — only those composables recompose when the State changes
- Write observation: when a State’s value changes, the Snapshot system notifies the Recomposer which composables are affected
- Snapshot (class) provides isolation — read-only Snapshots during recomposition ensure composables see consistent state (no tearing)
- Multiple state changes in one frame are batched into a single recomposition — efficient
- Recomposer (class) is the conductor — receives invalidations, schedules recomposition aligned with vsync, runs only invalid composables
- SnapshotMutationPolicy (interface) decides if a value “changed”:
equivalent(old, new) structuralEqualityPolicy()(top-level function, default) uses==(equals) — skips recomposition if values are equalreferentialEqualityPolicy()(top-level function) uses===(reference) — recomposes if it’s a different object even with same contentsneverEqualPolicy()(top-level function) always triggers recomposition — use sparinglyderivedStateOfuses Snapshot read observation to track source states and only notifies downstream when its result changes — frequency reductionsnapshotFlowbridges Snapshot reads to Flow world — re-runs lambda when observed states change, emits to Flow- The Snapshot system is thread-safe — concurrent reads are isolated, writes are synchronized internally
- Reads in Composition phase are tracked for recomposition; reads in Layout/Drawing phase (e.g.,
graphicsLayer { }) are NOT tracked — use this for performance
The Snapshot system is what makes Compose “reactive” without you writing any observation code. Every mutableStateOf is a tiny observable. Every composable function is an automatic observer. The Snapshot system connects them: recording reads during composition, detecting writes at any time, and scheduling recomposition only for the affected composables. It’s not magic — it’s a well-designed state observation engine that makes declarative UI possible.
Happy coding!
Comments (0)