Lesson 1

Movable content carries identity across locations

Trace how movableContentOf extracts remembered state, nodes, and invalidations, then inserts them at a new call site.

4 min readUpdated Jul 12, 2026

Evidence legend: Durable marks a stable mental model; Version-specific marks behavior tied to the AndroidX pin below; Experimental marks a changing or transitional path.

A remember value normally follows its position in one composition. What if a screen changes from a row to a column, but the editor, focus, or scroll state must follow the content rather than the old branch? movableContentOf makes that relocation explicit: the call creates a tracked content identity, and a later call can claim the state and nodes that left another location.

Extraction is more than moving a lambda

movableContentOf is marked @RememberInComposition; its returned lambda asks the current composer to insertMovableContent with an internal MovableContent holder and its parameter (MovableContent.kt#L34-L64). That holder gives each tracked lambda an identity even when two holders wrap the same function. The identity is not “the latest lambda object”; it is the runtime’s movable-content record at a composition location.

During apply, extractMovableContentAtCurrent creates a new slot table and removes the content from the source table. Its nodes leave their old parent, pending invalidations are extracted, and the resulting MovableContentStateReference retains content, parameters, source composition, slots, anchor, locals, nested references, and invalidations. A paired reference later represents insertion at the destination (Composer.kt#L1437-L1582, MovableContent.kt#L280-L311).

The composer documents two insertion cases: a paired reference moves released state from another location or composition; an unpaired reference creates new state for content that has no previous state (Composer.kt#L374-L398). After insertion, the destination composes over the moved group so changed parameters and new code are reconciled. The move is therefore a storage-and-node transfer, not a promise that the destination never executes composable code.

Diagram
flowchart LR
    A[Call leaves source] --> B[Extract slot state and nodes]
    B --> C[Transfer pending invalidations]
    C --> D[Insert at destination]
    D --> E[Recompose over moved state]

The pinned controls make this concrete. testMovableContentSharesState moves content from Row to Column and asserts the same remembered object; movableContentPreservesNodes asserts the same marker node and live remember observers. movableContentInvalidatedWhileDeleted covers link and gap composers when invalidation arrives during temporary absence (MovableContentTests.kt#L48-L133, MovableContentTests.kt#L1834-L1910).

Move, dispose, and recompose are different

EventState resultNode resultInvalidation result
Movable call leaves and a new call claims itRemembered state can transferExisting nodes can transferPending scopes move to the new owner
Ordinary content is removedSlots are forgotten; observers may be notifiedNodes are removedOld scopes no longer belong to active content
Same content is invalidated in placeExisting remembered values remainExisting nodes are updatedScope is scheduled for recomposition

Durable A historical release note says unclaimed movable state is eventually removed and remember observers are notified; it is not retained forever (Compose Runtime 1.2.0-alpha03). Composition.dispose() is the stronger terminal operation: it clears the hierarchy and releases composition resources (Composition.kt#L72-L90).

Reproduce the boundary

At an AndroidX checkout pinned to cc1caf65677fc10a8ce8116eba46e716f4cef222, run:

./gradlew :compose:runtime:runtime:desktopTest \
  --tests 'androidx.compose.runtime.MovableContentTests.testMovableContentSharesState' \
  --tests 'androidx.compose.runtime.MovableContentTests.movableContentPreservesNodes' \
  --tests 'androidx.compose.runtime.MovableContentTests.movableContentInvalidatedWhileDeleted_linkComposer' \
  --tests 'androidx.compose.runtime.MovableContentTests.movableContentInvalidatedWhileDeleted_gapComposer'

Controls: switch branch location, retain node identity, then invalidate while the old call is deleted. Expected: the remembered object and marker node survive relocation, and invalidation is not lost during the gap. Limit: mock-applier tests do not specify animation, saveable state, or custom-applier policy. This procedure was not executed in this content edit.

Misconceptions and check

  • movableContentOf is just a stable lambda.” Its runtime holder participates in extraction and insertion; a stable lambda alone does not transfer slot storage.
  • “A move means no recomposition.” The destination composes over the moved state; preservation of identity is not suppression of all execution.
  • “Temporary absence is disposal.” The runtime can hold movable state long enough for a new call to claim it; permanent unclaimed state is different.

Predict the result before looking at the test: if the same movableContentOf value is called in a different branch, which should remain identical—the remembered state object, the emitted marker node, or neither? The pinned controls answer: both state and node identity remain, while their parent location changes.

Evidence ledger

ClaimEvidenceLabel
Extraction transfers slot state, nodes, and pending invalidationsComposer.kt#L1437-L1582Version-specific
Relocation preserves a remembered object and node in pinned testsMovableContentTests.kt#L48-L133Version-specific
Unclaimed state is eventually forgottenCompose Runtime 1.2.0-alpha03Durable

Freshness

Refresh when movable-content extraction, pairing, invalidation transfer, or the gap/link composer implementations change. Keep preservation claims tied to the pinned tests; do not generalize them to arbitrary custom appliers or saveable state.

Finished this lesson?

Your progress stays only in this browser.