What Is a Snapshot? Understanding Compose's Isolated State World
What Is a Snapshot? Understanding Compose's Isolated State World
Jetpack Compose manages UI state through a system called Snapshots, a concept borrowed from database theory that enables isolated, concurrent access to shared mutable state. When you write var count by mutableStateOf(0), the runtime doesn't just store a value in a field. It creates a snapshot aware state object that participates in an isolation system where multiple threads can read and write state without interfering with each other. While most developers interact with snapshots implicitly through mutableStateOf and recomposition, the deeper question remains: what exactly is a snapshot, how does it provide isolation, and what happens when you "enter" one?
In this article, you'll dive deep into the Snapshot abstraction itself, exploring how the class hierarchy provides different levels of isolation, how the thread local mechanism makes snapshots invisible to the developer, how the GlobalSnapshot serves as the always present default, how advanceGlobalSnapshot makes changes visible, how nested snapshots enable hierarchical isolation, and how TransparentObserverSnapshot achieves zero cost observation. This isn't a guide on using mutableStateOf or snapshotFlow. It's an exploration of the isolation architecture that makes Compose's reactive state management possible.
The fundamental problem: Concurrent access to shared mutable state
Consider a typical Compose application:
@Composable
fun UserProfile() {
var name by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
Column {
Text("Name: $name")
Text("Email: $email")
Button(onClick = {
name = "Jaewoong"
email = "jaewoong@example.com"
}) {
Text("Load User")
}
}
}
This looks simple, but several problems lurk beneath the surface:
- Torn reads: If composition reads
nameafter the button click updates it but beforeemailis updated, the UI shows inconsistent state. - Concurrent composition: Compose may run composition on a background thread while the main thread is modifying state.
- Observation: The system must know that
UserProfilereadnameandemailso it can schedule recomposition when they change. - Batching: Multiple state changes from a single gesture should result in one recomposition, not one per change.
The naive solution of locking every read and write would kill performance. Compose solves all four problems with a single abstraction: snapshots. A snapshot is an isolated view of mutable state at a specific point in time. Reads within a snapshot always see a consistent view, writes are invisible to other snapshots until explicitly applied, and observers track exactly which state each composable depends on.
The Snapshot abstraction: An isolated view of state
At its core, a Snapshot is a sealed class that encapsulates a unique ID and a set of snapshot IDs that should be considered invisible:
// simplified
public sealed class Snapshot(
snapshotId: SnapshotId,
internal open var invalid: SnapshotIdSet,
) {
public open var snapshotId: SnapshotId = snapshotId
public abstract val root: Snapshot
public abstract val readOnly: Boolean
internal abstract val readObserver: ((Any) -> Unit)?
internal abstract val writeObserver: ((Any) -> Unit)?
}
Three properties define how a snapshot sees the world:
snapshotId: A monotonically increasing identifier allocated from a global counter. Every snapshot gets a unique ID, establishing a total ordering of snapshots.invalid: ASnapshotIdSetcontaining the IDs of all snapshots that were open (but not yet applied) when this snapshot was created. Records created by these snapshots are invisible.readOnly: Whether this snapshot allows writes. Attempting to modify state in a read only snapshot throws anIllegalStateException.
The readObserver and writeObserver are callback hooks that enable the reactive behavior. The read observer is called whenever state is accessed, allowing composition to track dependencies. The write observer is called when state is first modified, enabling eager invalidation.
The snapshot class hierarchy
The Snapshot sealed class branches into several concrete types, each serving a distinct purpose:
Snapshot (sealed)
├── MutableSnapshot (writable, isolated)
│ ├── GlobalSnapshot (the always present default)
│ ├── NestedMutableSnapshot (child of another mutable snapshot)
│ └── TransparentObserverMutableSnapshot (observation only, no isolation)
└── ReadonlySnapshot (read only view)
├── NestedReadonlySnapshot (child of another snapshot)
└── TransparentObserverSnapshot (read only observation, no isolation)
Each type exists for a specific reason. MutableSnapshot provides full isolation with write support. ReadonlySnapshot provides a frozen view. NestedMutableSnapshot enables hierarchical transactions. TransparentObserverMutableSnapshot enables observation without the overhead of isolation. Understanding when Compose uses each type is key to understanding the system's behavior.
Thread local isolation: Entering and leaving snapshots
The snapshot system uses a thread local variable to track which snapshot is "current" for each thread:
private val threadSnapshot = SnapshotThreadLocal<Snapshot>()
When you need to know the current snapshot, the runtime checks this thread local first, falling back to the global snapshot:
internal fun currentSnapshot(): Snapshot =
threadSnapshot.get() ?: globalSnapshot
This is the mechanism behind Snapshot.current. If no explicit snapshot has been entered on the current thread, you're operating in the global snapshot.
What "entering" a snapshot means
The enter() function makes a snapshot the current one for the duration of a block:
public inline fun <T> enter(block: () -> T): T {
val previous = makeCurrent()
try {
return block()
} finally {
restoreCurrent(previous)
}
}
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor