Four walls between you and a flight recorder for Compose state.
MutableState(value=47)@1387209841
If you have ever tried to build tooling on top of Compose, you have seen that line and felt the floor move. It is everything a state object will tell you about itself. Not which state. Not whose. A value and an identity hash.
I ran into it for a specific reason. I have an AI agent that inspects a running Compose app — reads the semantics tree, dumps state, screenshots, taps through screens. On paper it sees everything. In practice it is confidently wrong a lot, and always in the same way.
You rotate the device on the payment screen, come back, and the total is wrong. You ask the agent why. It reads the current state, reasons about it fluently, and gives you an answer that has nothing to do with anything — because it never saw the rotation. It sees the app the way you would see a photograph of a crime scene. Current state, perfect fidelity, zero history.
The Rule: a snapshot answers "what is true," and almost no interesting bug is a "what is true" question.
So I went to find out whether Compose will let you record the other thing. Not the state, the story.
What I Wanted, And What You Actually Get
I wanted a flight recorder. An ordered log of every state change, with the value before and after, what caused it, and what it caused in turn. Redux DevTools for the snapshot system.
Here is the whole answer, before any code, so you can decide how deep you want to go.
You can build about three-fifths of it. You can get an ordered log of state changes, with new values, correctly attributed to the composables they invalidated and the source lines those composables live on. That is genuinely useful and I did not expect to get it.
Four things stop the rest.
You cannot record every write, because the runtime does not consider a same-value write to be a write at all. You cannot get the previous value, because the record holding it is internal and gets overwritten in place. You can only see writes made during composition through a single experimental door that most people do not know exists. And you cannot reliably tell what any of it is called, because state objects have no names.
Three of those are engineering cost. The fourth is a design problem with no clean answer, and it is the one printed at the top of this post.
Everything below is verified against androidx-main at commit 90b822f2 (31 July 2026, in-tree COMPOSE_RUNTIME = 1.13.0-alpha01). Line numbers are pinned to that SHA rather than to a moving branch, so they will still be right when you read this.
Wall 1: No-Op Writes Are Invisible By Design
// SnapshotState.kt:141
override var value: T
get() = next.readable(this).value
set(value) =
next.withCurrent(this) {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
Under the default structuralEqualityPolicy(), assigning an equivalent value never enters overwritable. That matters more than it looks, because overwritable is the only path to a write observer — it ends in notifyWrite, which is what calls snapshot.writeObserver. recordModified lives on the same path.
Skip overwritable and you get no write observer, no recordModified, and the object never enters the modified set. So no apply observer sees it either. The runtime's own KDoc puts it plainly: "If [value] is written to with the same value, no recompositions will be scheduled."
This is correct behaviour and it is part of why Compose skips as well as it does. It also means "log every state write" is not a hard problem. It is an undefined one.
Wall 2: The Previous Value Is Overwritten In Place
StateRecord.snapshotId and StateRecord.next are internal (Snapshot.kt:1296 and :1317). SnapshotMutableStateImpl.StateStateRecord is private (SnapshotState.kt:191). StateObject.firstStateRecord is public, but you cannot traverse the chain or read a value off it.
And the record does not merely fall out of scope. overwriteUnusedRecordsLocked (Snapshot.kt:2216) reuses records in place:
recordToOverwrite.snapshotId = INVALID_SNAPSHOT
recordToOverwrite.assign(overwriteRecord)
assign copies the newer value over the older one. The old value is not hidden from you behind a visibility modifier. It is gone.
One honest caveat, from the runtime's own KDoc: this is best-effort — "this technique doesn't find all records that will not be selected by any open snapshot." So "eventually recycled" is accurate and "immediately destroyed" is not. Either way you cannot rely on reading it.
The workaround is the obvious one: keep your own WeakHashMap<StateObject, Any?> of last-seen values, updated from a write observer that fires just before the write commits.
Wall 3: Composition Writes Have Exactly One Door
This is the wall I got wrong the first time, and the correction is more interesting than the mistake.
Recomposer.composing() takes its own MutableSnapshot, with its own read and write observers (Recomposer.kt:1490):
val snapshot = Snapshot.takeMutableSnapshot(
readObserverOf(composition),
writeObserverOf(composition, modifiedValues),
)
And global write observers are deliberately not merged into it. That is not an oversight — there is a comment saying so, at Snapshot.kt:1575:
// It is intentional that global write observers are not merged with mutable
// snapshots write observers.
So registerGlobalWriteObserver will never see a write made during composition. I originally concluded there was no seam at all. There is one, and it is easy to miss:
@ExperimentalComposeRuntimeApi
public fun Snapshot.Companion.observeSnapshots(
snapshotObserver: SnapshotObserver
): ObserverHandle
snapshots/tooling/SnapshotObserver.kt:148
SnapshotObserver.onPreCreate returns SnapshotInstanceObservers(readObserver, writeObserver), and those get merged into every snapshot as it is created — including, via GlobalSnapshot.takeNestedMutableSnapshot, the one the Recomposer takes for composition.
So composition-time writes are reachable. Through an experimental API, from a tooling package, with no stability guarantee. That is a very different sentence from "impossible," and if you are building here it is the difference between shipping and not.
The Rule: "no stable public API" and "no public API" are not the same claim. Check which one you are making.
Wall 4: Nothing Has A Name
This is where we came in, and it is the one that actually kills the project.
// SnapshotState.kt:188
override fun toString(): String =
next.withCurrent(this) { "MutableState(value=${it.value})@${hashCode()}" }
That is the whole identity story. A value and a default identity hash, printed in decimal. There is no Android override — ParcelableSnapshotMutableState does not define toString — so this is what you get everywhere.
Mapping that back to CheckoutViewModel.uiState needs one of three things, and all three are partial:
- Walk
CompositionGroup.dataslot tables. Only works for state created inside composition, and requirescollectParameterInformation(). - Reflect over the owning object's fields. Works for ViewModels, fails for anything anonymous, costs you on every lookup.
- Capture an allocation stack trace at
mutableStateOftime. Expensive, and gives you a creation site rather than a name.
None is a public affordance. Compose was not designed to let you name state, because nothing inside Compose needs to.
The Rule: walls 1 through 3 are engineering cost. Wall 4 is a design problem — budget for it first, not last.
A recorder that reports @1387209841 changed to 47 is not a recorder. It is a hex dump with timestamps, except not even in hex.
What Survives: The Causal Log
Now the good part, and it is better than I expected going in.
// androidx.compose.runtime.tooling.CompositionObserver
public fun onScopeInvalidated(scope: RecomposeScope, value: Any?)
tooling/CompositionObserver.kt:151
That is a causal edge. Not "this state changed" over here and "this composable recomposed" over there, but this value invalidated that scope — both halves in one callback.
Be precise about when it fires, because I was sloppy about this at first and the KDoc is explicit:
Note that for invalidations caused by a state change, this callback is not called immediately on the state write. Usually, invalidations from state changes are recorded right before recomposition starts or during composition. This method is always guaranteed to be called before onScopeEnter for the corresponding invalidation is executed.So it is not a write-time hook. It is a pre-recomposition hook with a hard ordering guarantee relative to the recomposition it explains. For building a causal log that is exactly what you want — you get cause attached to effect, ordered, before the effect runs. You just cannot use it as your write timestamp.
Everything else you need is around it. onBeginComposition and onEndComposition give pass boundaries. onScopeEnter / onScopeExit / onScopeDisposed give lifecycle. onReadInScope gives the dependency graph. Recomposer.observe() attaches you to every composition in the process. IdentifiableRecomposeScope.identity gives a stable ID across recompositions, which you need if the log is going to mean anything over more than one frame. parseSourceInformation() turns CompositionGroup.sourceInfo into a function name, file and line.
So the achievable artifact is:
An ordered, bounded log of effective state changes, with new values, attributed to the recompose scopes they invalidated and the source locations of those scopes.
No old values. No no-op writes. Composition-time writes only through an experimental door. Names only where you can resolve them. Time-travel replay permanently off the table, because you cannot replay through a state you never captured.
But sit with what that actually answers. It answers what changed, and what did it cause. That is the question you have at 2am. "What was it before" is usually reconstructible once you know the causal chain. I went in wanting a replay debugger and came out thinking the causal log is the more useful thing.
A small aside, because it annoyed me
Every restartable composable receives a $changed bitmask — three bits per parameter slot, encoding Same, Different, Static or Uncertain. It is a direct answer to "which argument actually changed," and the compiler hands it to the tracer at runtime.
The shipped runtime-tracing artifact throws it away:
override fun traceEventStart(key: Int, dirty1: Int, dirty2: Int, info: String) =
PerfettoSdkTrace.beginSection(info)
key, dirty1 and dirty2 all discarded. Only info survives.
Install your own CompositionTracer and you keep them. Composer.setTracer (Composer.kt:951) is annotated @InternalComposeTracingApi — an opt-in marker, not Kotlin internal — and lives in commonMain, so it works on every Compose Multiplatform target. runtime-tracing itself is an com.android.library with only src/main, so the initializer is Android-only even though the hook is not.
Picking Your Observer
Once you know what you are building, the choice between the two snapshot observers is forced.
registerApplyObserver |
registerGlobalWriteObserver |
|
|---|---|---|
| Fires | at snapshot apply | at write time, synchronously |
| Thread | notifier's | the writing thread |
| Granularity | coalesced set per apply | one call per write |
| Order preserved | no | yes |
| Gives you | Set<Any> of identities |
one Any |
| Sees composition writes | as coalesced identities | no — needs observeSnapshots |
Signatures at Snapshot.kt:630 and :654.
The coalescing is the deciding factor. Multiple writes to one state inside a single snapshot arrive at the apply observer as one set element carrying only the final value. If you want order, you want the write observer, and you take the composition blindness that comes with it — or you reach for observeSnapshots and accept the experimental annotation.
Use both: the write observer for the sequence, the apply observer for commit boundaries.
Two Things That Will Bite You
Your write observer runs under the global snapshot lock. The wiring is sync { globalWriteObservers.fastForEach { it(state) } }. Your callback executes on the writing thread, holding the lock, for every effective state write in the process.
The only viable shape is O(1). Append (nanoTime, ref, ref) to a preallocated ring buffer and do all naming, formatting and stringification off-thread. Get this wrong and you are not measuring the app, you are measuring your instrumentation.
collectParameterInformation() costs allocations, but does not disable skipping. This one I had backwards, so it is worth being exact.
It sets the field forceRecomposeScopes = true (GapComposer.kt:611), whose only effect is in endRestartGroup:
if (scope != null && !scope.skipped && (scope.used || forceRecomposeScopes)) {
Every restartable group keeps an anchored RecomposeScope even if nothing in it read state — so every composable becomes independently restartable and inspectable. That is an allocation cost, and the Composer KDoc warns of "a significant number of additional allocations that are typically avoided."
It is a different thing from the method forceRecomposeScopes(), which sets forciblyRecompose and is what actually feeds the skipping check. collectParameterInformation() never calls it. If you have read elsewhere that this flag turns skipping off — I wrote that too, and it is wrong.
And the stability picture, because anyone building on this deserves it up front:
| API | Status |
|---|---|
registerApplyObserver, registerGlobalWriteObserver, Snapshot.observe |
stable |
SnapshotStateObserver, collectParameterInformation |
stable |
CompositionObserver, Recomposer.observe, Snapshot.observeSnapshots |
@ExperimentalComposeRuntimeApi |
IdentifiableRecomposeScope, parseSourceInformation |
@ComposeToolingApi |
CompositionTracer, Composer.setTracer |
@InternalComposeTracingApi |
sourceInfo string format |
documented as internal |
Half of what makes this possible is experimental or opt-in. Pin your Compose version and expect to fix things.
If You Are Building On This
In the order it will hurt you:
- Solve naming first. Wall 4 is the project. Everything else is a sprint.
- Build the causal edge on
onScopeInvalidated— and treat it as a pre-recomposition hook, not a write hook. - Reach for
observeSnapshotsonly if you truly need composition-time writes. It is the most experimental thing on the list. - Keep the write observer O(1). Ring buffer in, formatting out-of-band.
- Bound the log. A complete session journal is unbounded memory by definition.
- Do not promise time-travel. Someone will ask, and you cannot replay a state you never captured.
I went looking because I build ComposeProof, which gives AI coding agents visibility into a running Compose app — headless @Preview rendering, live state inspection, on-device API mocking. It is mine, there is a free tier, and these four walls are exactly why I ended up in the source rather than guessing.
The question I actually wanted answered was whether "give the agent the story instead of the snapshot" is an engineering problem or an impossible one. It is neither. It is an engineering problem with a naming problem sitting in the middle of it — and that is worth knowing before someone spends a quarter finding out the expensive way.
Source references. All pinned to 90b822f25e68f815f96d3e71eb1774c4375c3669, androidx-main as of 31 July 2026. Path prefix: compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/
| Claim | File : line |
|---|---|
| Equality short-circuit | SnapshotState.kt:141 |
toString() |
SnapshotState.kt:188 |
StateStateRecord private |
SnapshotState.kt:191 |
| Observers | snapshots/Snapshot.kt:630, :654 |
next / snapshotId internal |
snapshots/Snapshot.kt:1296, :1317 |
| Write observers not merged | snapshots/Snapshot.kt:1575 |
| Record overwrite | snapshots/Snapshot.kt:2216, :2249 |
observeSnapshots |
snapshots/tooling/SnapshotObserver.kt:148 |
onScopeInvalidated |
tooling/CompositionObserver.kt:151, KDoc :137 |
composing() snapshot |
Recomposer.kt:1490 |
setTracer |
Composer.kt:951 |
collectParameterInformation |
GapComposer.kt:611, effect at :2187 |
Next: what an agent can query about system state, where the answer turns a 57MB Perfetto trace into a 593-byte SQL result.