Lesson 1

Side effects run after a successful apply

Trace SideEffect and DisposableEffect from composition recording to apply-phase callbacks and remembered lifecycle.

3 min readUpdated Jul 12, 2026

Status: The rule that a side effect must not publish an un-applied result is Durable . The precise dispatcher sequence below is Version-specific .

Outcome

Explain why a SideEffect can safely update an object outside snapshots only after changes apply, and why DisposableEffect is a remembered observer rather than code that runs during the composable body.

The body records; apply dispatches

Calling SideEffect { ... } does not run the lambda. At the pinned implementation it calls currentComposer.recordSideEffectWithTracing, which delegates to the composer’s recordSideEffect queue (Effects.kt, Composer.kt). The composable body therefore describes work; it does not get permission to mutate the external object yet.

The pinned Composition.applyChangesInLocked gives that queue a meaningful boundary: it calls applier.onBeginChanges(), executes the change list, calls applier.onEndChanges(), dispatches remembered-object callbacks, and only then dispatches side effects (Composition.kt). If change execution fails, the normal path does not reach side-effect dispatch; the finally path handles abandonment instead. That is why a side effect is not a transaction commit and not a callback from the middle of tree mutation.

Diagram
flowchart LR
    Body[Composable body] --> Queue[Record side effect]
    Queue --> Changes[Execute changes]
    Changes --> End[End apply]
    End --> Remember[Dispatch remember callbacks]
    Remember --> Side[Dispatch side effects]

SideEffect(effect) records its callback for the apply boundary; it is not the keyed identity mechanism discussed in this lesson (Effects.kt). For keyed identity, use DisposableEffect: its keyed overloads create the remembered observer through remember (Effects.kt). The key comparison then controls disposal and replacement, not whether SideEffect runs in the body.

DisposableEffect is a remembered object

DisposableEffectImpl implements RememberObserver. Its onRemembered invokes the effect block and stores the returned DisposableEffectResult; onForgotten calls dispose and clears it; onAbandoned does nothing because the effect was never successfully remembered (Effects.kt). Each keyed overload constructs that observer through remember(key...) (Effects.kt). Ordinary remember uses composer key comparison to decide whether its cached slot can be reused (Composables.kt).

A key change consequently means: forget the old remembered effect, run its disposal during lifecycle dispatch, create and remember a new observer, then run the new effect. Removal has the same remembered-to-forgotten shape. The upstream EffectsTests.testCommit1 and testCommit4 controls record body logs before commit, disposal on removal, and disposal/re-creation after a key changes (EffectsTests.kt).

Reproduce the boundary

At an AndroidX checkout pinned to cc1caf65677fc10a8ce8116eba46e716f4cef222, run:

./gradlew :compose:runtime:runtime:desktopTest \
  --tests 'androidx.compose.runtime.EffectsTests.testCommit1' \
  --tests 'androidx.compose.runtime.EffectsTests.testCommit4' \
  --tests 'androidx.compose.runtime.EffectsTests.testCommit6'

Controls: testCommit1 removes a branch; testCommit4 changes an effect key; testCommit6 writes state from a SideEffect. Expected: body logging precedes effect callbacks, disposal precedes replacement, and the post-apply state write causes the observed follow-up change. Limit: these tests do not define frame scheduling, cross-composition ordering, or failure recovery for every applier. This procedure was not executed for this lesson.

Misconceptions

  • “The effect lambda runs as the body reaches it.” The body records it; apply dispatch runs it later.
  • DisposableEffect is a try/finally around a composable call.” It is a remembered observer with explicit forgotten and abandoned paths.
  • “Side effect means successful composition.” It means the queued callback ran after this apply path; it does not make a failed apply successful.

Check yourself

A composition calculates a SideEffect and then change execution throws. Which callback should update the external object, and which lifecycle path should account for newly created remembered objects?

Evidence ledger

ClaimDirect evidenceStatus
SideEffect records a callback instead of running it in the bodyEffects.kt and Composer.ktDurable Version-specific
Apply closes before remembered callbacks and side effects dispatchComposition.ktVersion-specific
DisposableEffectImpl maps remembered, forgotten, and abandoned pathsEffects.ktDurable Version-specific
Key changes dispose and recreate an effect in the upstream controlsEffectsTests.ktVersion-specific

Freshness

Refresh when the runtime changes the recordSideEffect queue, applies lifecycle callbacks in a different order, or changes DisposableEffectImpl’s observer contract. Do not infer scheduler behavior from this apply-phase lesson.

Finished this lesson?

Your progress stays only in this browser.