Jetpack Compose Snapshot State: From MutableState to Recomposition
After Strong Skipping Mode landed officially in Compose 1.7, I noticed that many developers on the team understood it only as “skipping unnecessary recomposition.” They could not explain why those unnecessary recompositions happened in the first place. The root is the Snapshot system itself: a reactive runtime most people rarely look at closely.
Snapshot is not a reactive framework. It is MVCC
Many developers think of mutableStateOf as “an observable variable.” That is not wrong, but it is too shallow. Compose’s state system is essentially a multi-version concurrency control system (MVCC), and it shares the same design philosophy as database MVCC.
Behind every MutableState<T> is a SnapshotMutableStateImpl. It does not directly hold T; it holds the head of a linked list of StateRecord objects. Every write inserts a new record at the head of the list, and old records are not immediately removed.
// androidx.compose.runtime.snapshots.SnapshotMutableStateImpl (simplified)
internal class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObject {
// Does not store T directly. It stores the head of a StateRecord chain.
override var firstStateRecord: StateRecord = StateStateRecord(value)
private inner class StateStateRecord(val value: T) : StateRecord() {
override fun assign(value: StateRecord) { /* ... */ }
override fun create(): StateRecord = StateStateRecord(this.value)
}
}
On read, the runtime walks the linked list and finds the latest record visible to the current Snapshot version. On write, it first checks whether the current thread is inside a writable Snapshot, then wraps the new value as a StateRecord and inserts it at the head.
This means two coroutines can modify the same State at the same time without seeing each other’s changes until their Snapshots are applied. Compose uses this mechanism to provide thread-safe state isolation without explicit locks.
GlobalSnapshot and apply observers
The system has a global snapshot, GlobalSnapshot. Every state.value read inside a Composable is registered in the readObserver of the currently active Snapshot. This is where recomposition first becomes aware of state changes.
The full recomposition trigger chain is:
write -> apply -> notify observers -> schedule recomposition
// Global notifications run after Snapshot.apply()
Snapshot.registerApplyObserver { changedSet, snapshot ->
// changedSet: the StateObject set modified by this apply
// The runtime finds which RecomposeScopes subscribed to these objects
invalidateAffectedScopes(changedSet)
}
When Recomposer starts, it calls Snapshot.registerApplyObserver and attaches itself. Every time a Snapshot is applied, the observer receives a Set<Any> containing all modified StateObject instances. The Recomposer walks that set, finds the RecomposeScope objects that subscribed to those state objects, marks them dirty, and schedules recomposition for the next frame.
On the UI thread, assigning state.value = newValue does not immediately apply the Snapshot. At the start of each frame, the Compose framework explicitly calls Snapshot.sendApplyNotifications() and batches all writes from that frame into one notification pass. That is why multiple State writes within the same frame usually trigger only one recomposition pass.
RecomposeScope subscriptions and invalidation
RecomposeScope is the smallest recomposition unit generated by the Compose compiler. It corresponds to a restartable lambda. Every time a Composable runs, the runtime sets the current RecomposeScope as active and then executes the function body. Every state.value read inside that body binds the State to the current Scope through readObserver.
// Pseudocode showing how the subscription relationship is established
val currentScope = currentRecomposeScope // Set implicitly by the runtime
// Inside the state.value getter
override var value: T
get() {
// On read, record the current scope as a subscriber
Snapshot.current.recordRead(this)
return readable.value
}
recordRead stores a mapping of StateObject -> Set<RecomposeScope> in the current Snapshot’s read set. When apply runs, this mapping becomes the index used to find affected Scopes.
A common misconception: subscriptions are rebuilt after every recomposition. The set of State objects read during the previous recomposition is cleared before the next recomposition starts, then recorded again after recomposition finishes. If a later recomposition skips a State read because an if branch did not execute, that Scope no longer subscribes to the State, and later writes to that State will not recompose it.
One pitfall I have hit: reading State inside LaunchedEffect runs in an independent coroutine and in a different Snapshot context from Composition. The read is not captured by the Composition readObserver. If you want LaunchedEffect to respond to a State change, put that State into the key list or use snapshotFlow { state.value } to establish an explicit subscription.
Derived state and the optimization logic behind derivedStateOf
derivedStateOf creates a DerivedSnapshotState. It is also a StateObject, but it has two layers of subscription:
- Internal subscription: it subscribes to the State objects it depends on, meaning all State objects read inside the calculation lambda
- External subscription: the Composable subscribes to the result value of the derived state
val filtered by remember {
derivedStateOf { items.filter { it.isActive } } // items is a SnapshotStateList
}
When items changes, derivedStateOf internally recalculates the lambda and compares the old and new values using MutationPolicy (structural equality by default). Only when the computed result actually changes does it notify the outer Composable to recompose. This prevents high-frequency State changes, such as scroll offset, from blindly triggering downstream recomposition.
Strong Skipping Mode solves a different problem: skipping recomposition when parameters are equal. It operates at a different layer, so the two mechanisms are not substitutes for each other.
Apply conflicts and MutationPolicy
In multithreaded scenarios, two Snapshots can modify the same State at the same time, producing a conflict during apply. Compose resolves this through SnapshotMutationPolicy:
// Three built-in policies
structuralEqualityPolicy() // Treat values as non-conflicting if equals() says they are equal
referentialEqualityPolicy() // Treat values as non-conflicting if === says they are equal; used by remember by default
neverEqualPolicy() // Always treat the value as changed; every assignment triggers recomposition
When a conflict occurs, the Snapshot system calls policy.merge(previous, current, applied) and tries to merge the values. If merge returns null, apply fails and the caller needs to retry. You rarely hit this when using ViewModel plus StateFlow, but if you write Compose State directly from a background thread, you need to understand this path.
My preference is to keep mutable state in StateFlow inside the ViewModel and let the Compose layer consume it through collectAsStateWithLifecycle(). That avoids multithreaded Compose State writes entirely. Direct writes to Compose State should normally happen only on the main thread.
Practical guidance
Use snapshotFlow to bridge Snapshot and the coroutine world. When you need to react to State changes inside a coroutine, snapshotFlow { state.value } emits whenever the value changes and handles Snapshot semantics correctly. It is much cleaner than polling inside LaunchedEffect.
LaunchedEffect(Unit) {
snapshotFlow { scrollState.value }
.distinctUntilChanged()
.collect { offset -> /* respond to scroll */ }
}
When diagnosing excessive recomposition, inspect the read path first, not only the writer. Layout Inspector’s recomposition count tells you “who recomposed,” not “who triggered it.” Setting a breakpoint on readObserver, or temporarily replacing a State policy with neverEqualPolicy() to validate a hypothesis, is usually more effective than blindly adding key.
The calculation lambda passed to remember is not in a Snapshot read context. If remember { expensiveCalculation() } reads State inside expensiveCalculation, that read does not establish a subscription and will not trigger recomposition. The result of remember depends only on its keys. This is a frequent cause of “State changed, but the UI did not update.”
Once you understand where readObserver is established and when apply is triggered, many questions about why a given place needs derivedStateOf answer themselves. The Snapshot system combines concurrency safety, fine-grained subscription, and lazy evaluation into one model. Understanding its boundaries is far more valuable than memorizing API usage.