Mobile Computing · UI Frameworks
Recomposition Scopes: How Granularity Decides What Reruns
Why a state read is only observed by the nearest enclosing scope, how dependency tracking limits the work a single value change triggers, and why state hoisting changes cost.
A counter whose value is read at the top of a screen and displayed at the bottom causes the whole screen to be re-executed when the value changes. The same counter, with the read moved down into the label that displays it, causes only the label to be re-executed. The rendered result is identical in both cases. The difference is which functions run, and on a screen of any complexity that difference dominates the frame’s cost.
This is the central performance property of fine-grained reactive frameworks, and it is not an optimization a developer applies afterwards. It is a consequence of where the state read happens, decided when the code is written. Understanding the mechanism is what makes the choice deliberate.
What a state read registers
In a tracked system, reading a state object during execution does two things: it returns the current value, and it records that the currently executing scope depends on that state. The record is a subscription. When the state later changes, the system walks its subscriber list and marks each subscriber as invalid, then schedules a pass that re-executes the marked scopes.
The scope is the unit of subscription, and its extent is defined by the runtime. In Compose the nearest enclosing restartable composable function is the scope; a composable is restartable if the compiler can identify a group around its body and can re-enter it without re-entering the caller. Reading a MutableState inside that body subscribes the composable to it. Reading it inside a lambda that is not itself restartable, such as an event handler, does not subscribe anything, because the read happens outside a composition.
Compose’s snapshot system makes the read observable by wrapping the state’s value in a snapshot that records reads. A snapshot is an immutable view of a set of state objects; when a state object is written, the write is applied to a new snapshot, and the framework compares the read set of each scope against the changed objects. Only scopes whose read set intersects the changes are invalidated. Writes that occur inside a snapshot that is later discarded, as happens when a composition is abandoned, produce no invalidations at all.
React’s model is coarser by design. A state setter schedules a re-render of the component that owns the state and, because the parent produces new child descriptions, of every descendant that is not memoized on referential equality. There is no subscription per scope; the dependency graph is the component tree, and the granularity is the component. That is why React’s performance advice centres on memoization and on keeping state low in the tree: moving a state down reduces the subtree that rerenders, and memoizing a child cuts the cascade at that boundary. The position of the state in the tree also decides which component instances keep their stored values across the rerender, which is the identity question treated in Element Identity and State Retention Across Rebuilds.
Vue and Svelte sit closer to the fine-grained end. A reactive proxy records which effect depends on which property, and a change to one property invalidates only the effects that read it, with component-level and finer-grained scopes depending on the compiler’s output. The spectrum from coarse to fine is a spectrum of how much bookkeeping the runtime performs per read, and how precisely it can localize the resulting work.
Granularity is bounded by what the scope can be re-entered with
A scope cannot be arbitrarily small, because re-executing it requires the runtime to reconstruct its inputs. A composable’s parameters are part of its group’s state, so the group can be re-entered with the same parameters. A scope inside a loop has an iteration index and an item key in its group identity, so each iteration is a separate restartable scope and can be invalidated independently. A scope that closes over a local variable computed earlier in the same function cannot be re-entered without re-entering the function, because the variable would have to be recomputed; the compiler therefore cannot make it restartable, and the invalidation propagates outward to the enclosing scope.
That is the mechanism behind a piece of advice that otherwise sounds arbitrary: pass values as parameters rather than capturing them. A child that takes a value as a parameter has that value in its group state, and it can be re-entered. A child that reads a value from an enclosing local variable has a dependency on the enclosing function’s execution and forces the whole thing to rerun. The same rule explains why a lambda allocated inline in a composable body is a stability problem: a new lambda instance each pass is a new parameter value, so the child’s parameters are never equal and it can never skip.
Reading late versus reading early
The most consequential pattern in fine-grained systems is deferring the read as far down the tree as possible, and the sharpest form of it is passing a function that returns the value rather than the value itself.
// Reading `offset` here subscribes this composable, so every scroll frame
// re-executes the whole body including the header and the list.
@Composable
fun Screen(scroll: ScrollState) {
val offset = scroll.value
Header(title = "Inbox")
MessageList(scrollOffset = offset)
}
// Reading it inside the consumer moves the subscription down; the header
// is not re-executed and the parameter is a stable function reference.
@Composable
fun Screen(scroll: ScrollState) {
Header(title = "Inbox")
MessageList(scrollOffset = { scroll.value })
}
The second version moves the subscription into MessageList, where the value is actually consumed. The header no longer re-executes, and the parameter passed down is a function that captures the state object, which is stable across passes because the state object itself does not change identity. This is the same idea as React’s habit of passing a setter or a selector rather than a value, and as the pattern of subscribing a small leaf component to a store slice.
The cost is indirection and a slightly harder read. The benefit is that a value changing at high frequency, a scroll offset, an animation clock, a text field’s contents, only invalidates the node that draws it. On a list of a thousand rows, an animation that invalidates the list container instead of the row indicator is the difference between a smooth frame and a dropped one, and it is invisible in the rendered output.
Invalidation is not the same as execution
Marking a scope invalid does not execute it immediately. The invalidation adds the scope to a set, and the set is drained on the next frame, before layout. Compose calls this recomposition, and it runs on the frame’s main-thread phase alongside measure and layout; the snapshot apply that makes the new values visible happens at the start of that pass, so a scope executed during the pass sees a consistent view of all state written since the previous pass.
Two consequences follow. First, several writes to the same state in one frame collapse into one invalidation, so a loop that appends a hundred items one at a time and a loop that appends them in a single batch cost the same number of recompositions, though the second may cost fewer snapshot applies. Second, a state written during the pass and read by a scope already executed in that pass causes that scope to be invalidated again for the next frame; the framework detects the case where this would loop indefinitely and stops after a bounded number of passes rather than hanging.
React’s equivalent is the render phase and the commit phase, with the important difference that a render pass must be pure and the commit applies mutations afterwards. A state update during render of the same component is a bug in React’s model, and the framework reports it. Compose permits it and schedules another pass, which is a different trade: more permissive, with a liveness risk the runtime has to bound.
Where the dependency graph is wrong
Dependency tracking observes reads that happen during execution. It cannot observe a read that happens conditionally on a branch that did not execute, and it cannot observe a read that happens inside a callback invoked later. Both produce the same class of bug: a value changes and the scope that displays it is not invalidated, so the display goes stale.
The first case is the subtle one. A composable that reads state A in one branch and state B in the other is subscribed to whichever branch ran. When A changes such that the condition flips, the scope is invalidated by A and re-executes, reads B, and subscribes to B. That works. But if the condition depends on state C that was never read in this scope, the flip never happens, because nothing invalidates the scope. The dependency is on a value the scope never touched, and no tracking system can infer it. This is why the recommendation is to read the condition, not just its consequences.
The second case is a read inside a callback that captures the state object. Reading state.value inside a click handler returns the current value at click time and subscribes nothing, which is correct for the handler and wrong for any code that expected the handler’s read to keep something fresh. A lambda that is re-created only when its inputs change, and that captures a state object read later, is the standard way to get fresh values without subscribing.
Measuring the effect without measuring
The granularity of invalidation is observable without a profiler by reasoning about which functions run. A counter incremented by an animation at 60 Hz inside a screen whose body reads it will execute the body 60 times per second; the same counter read only by a leaf executes the leaf 60 times per second. The ratio of the two costs is the ratio of their body sizes, and it can be estimated from the number of described nodes each produces.
The frameworks provide tooling that makes the count visible: Compose’s recomposition counts in the layout inspector, React’s profiler flame charts with per-component render counts. Those numbers are measurements of a specific build and device and are worth reading rather than quoting, since they depend on the compiler, the device, and the debug or release variant. The structural fact, that a read subscribes exactly the enclosing scope and nothing above it, holds regardless, and it is what separates invalidation from the mutation work that follows it in Reconciliation: Turning a Declarative Description into Minimal Mutations.
References
- Android Developers: Compose Mental Model — Google
- Android Developers: Compose Performance — Google
- React Documentation: Preserving and Resetting State — Meta Open Source