movableContentOf: How Compose Moves a Subtree Without Losing Its State
movableContentOf: How Compose Moves a Subtree Without Losing Its State
Compose developers learn early that remember survives recomposition but not repositioning. Move a composable from one branch of an if to another, or from a Row into a Column, and its remembered state snaps back to its initial value, because as far as the runtime is concerned a new composable appeared at a new place and the old one was thrown away. movableContentOf changes that. It marks a subtree as portable, so that when the same content shows up at a different call site, its remembered values, its effects, and its layout nodes travel with it instead of being discarded and rebuilt. Adaptive layouts that reposition content as the window resizes are exactly where this matters. The surface API is one function, but underneath the runtime physically lifts a group of slots out of one location in its data structure and splices it back in somewhere else.
In this article, you'll dive deep into the internals of movableContentOf, exploring how a movable subtree is registered under a stable identity, how the composer marks its group in the slot table, how the runtime detects that the content left one call site and arrived at another, how the departed subtree is extracted into its own slot table and parked, how a departure is paired with an arrival by identity, how the parked slots and layout nodes are transplanted into the new location without re-running remember, and how all of this differs from key() and the movable groups the compiler generates for it.
The fundamental problem: Moving a composable resets its state
Start with a counter whose state lives in remember:
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { Text("Clicked $count") }
}
Now place that counter in one of two layouts depending on a flag:
@Composable
fun MovableDemo(inColumn: Boolean) {
if (inColumn) {
Column { Counter() }
} else {
Row { Counter() }
}
}
Click the button a few times, then flip inColumn. The count resets to zero. The reason is positional identity. Compose does not track composables by object reference. It tracks them by where their calls land in a flat structure called the slot table. When inColumn flips, the Counter() call under Column disappears and a Counter() call under Row appears at a different location. The runtime does not know these are the same logical widget. It disposes the first, which forgets its remember, and it builds the second from scratch. Every mutableStateOf, every DisposableEffect, every layout node is torn down and recreated.
Wrapping the counter in key() does not help here, and neither does hoisting the state, because hoisting only relocates the problem: whatever holds the state now has to move too. What you want is a way to tell the runtime that a specific subtree is one identity that can appear at different places over time, and that when it does, its slots should be carried over rather than rebuilt. That is what movableContentOf provides, and to see how, you first need to know what a slot actually is.
The slot table: where composition state actually lives
Compose stores the running state of a composition in a structure called the slot table. In the default runtime it is backed by a gap buffer, which is a flat array with a movable empty region that makes inserts and deletes near a cursor cheap. Conceptually the slot table is a flattened tree. Each composable call opens a group, and a group holds everything that call produced: the values you passed to remember, the holder objects for your RememberObserver effects, references to layout nodes, and the nested child groups for the composables it called. A group's position in this array is its identity. That is the root cause from the previous section. When Counter lands under Row instead of Column, it occupies a different position, so the runtime treats it as a different group.
Two features of a group make movable content possible. The first is an anchor, which is a stable handle to a group that survives edits to the array. The runtime uses anchors to keep track of where a group is even as slots shift around it. The second is a mark bit. A group can be flagged, its ancestors get a separate "contains mark" flag so a scan can find flagged descendants quickly, and the deletion path scans for exactly this bit. Ordinary composables never set it. movableContentOf is the main API you call directly that relies on this bit, and that mark is the thread the whole mechanism pulls on. Subcompositions set it too: composition context groups, the ones SubcomposeLayout, dialogs, and popups build through rememberCompositionContext, carry the same mark, which is why the deletion scan later handles both cases.
There are actually two composer implementations in the tree. The gap buffer composer is the default, and a linked list slot table variant exists behind a feature flag with the same movable content design. Everything below traces the default gap buffer path. The shared base type both variants extend is SlotStorage, which is why the reference and state classes you are about to meet hold a slotStorage, downcast to the concrete table when needed.
Registration: a stable identity, not a lambda
Here is the fix, and the whole point of the API, written the canonical way:
@Composable
fun MovableDemo(inColumn: Boolean) {
val content = remember {
movableContentOf {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { Text("Clicked $count") }
}
}
if (inColumn) Column { content() } else Row { content() }
}
Now flip inColumn and the count survives. To understand why, look at what movableContentOf returns:
@RememberInComposition
public fun movableContentOf(content: @Composable () -> Unit): @Composable () -> Unit {
val movableContent = MovableContent<Nothing?>({ content() })
return { currentComposer.insertMovableContent(movableContent, null) }
}
It creates one MovableContent holder object, wraps your lambda in it, and returns a new lambda. Every time that returned lambda runs at a call site, it calls currentComposer.insertMovableContent(movableContent, null), passing the same holder. The holder is the identity:
@InternalComposeApi
public class MovableContent<P>(public val content: @Composable (parameter: P) -> Unit)
The holder exists so the runtime does not have to rely on the lambda's own identity. The Compose compiler may merge identical lambdas into singletons, and identity that changes between debug and release builds would make pairing unreliable. A dedicated holder object is stable and unique per movableContentOf call. This is also why the function carries @RememberInComposition and why the usage example wraps it in remember. If you rebuild the movable content on every recomposition, you mint a new holder each time, the departure and arrival can no longer be matched, and the state resets. Hoisting the holder is not a style preference. It is what keeps the identity alive.
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