Lesson 2

LaunchedEffect belongs to a keyed composition scope

Trace coroutine context, cancellation, rememberCoroutineScope ownership, and effect keys as lifetime identity.

4 min readUpdated Jul 12, 2026

Status: An effect coroutine owned by a composition point is Durable . Its parent context, cancellation exception, and lazy scope internals are Version-specific .

Outcome

Given a LaunchedEffect or rememberCoroutineScope call, identify who owns the Job, what a key change means, and why callback-started work should use a remembered scope instead of using state as a launch trigger.

LaunchedEffect has a remembered owner

At the pinned source, LaunchedEffect(key...) reads currentComposer.applyCoroutineContext and remembers a LaunchedEffectImpl under those keys (Effects.kt). The implementation builds a CoroutineScope from that parent context. When its RememberObserver.onRemembered callback arrives, it launches the task; onForgotten and onAbandoned cancel the job with the composition-exit cancellation exception (Effects.kt).

Diagram
flowchart TD
    Enter[Effect enters] --> Remember[Remember keyed owner]
    Remember --> Launch[Launch child job]
    Key[Key changes] --> Forget[Forget old owner]
    Forget --> Cancel[Cancel old job]
    Cancel --> New[Remember new owner]
    New --> Launch
    Exit[Effect leaves] --> Forget

This makes the key an identity boundary, not merely an input argument. Same key at the same composition position reuses the remembered owner; a different key forgets that owner and creates a new one. The new coroutine is therefore a consequence of a new remembered instance. The exact equality path is the ordinary composer remember comparison, not a special coroutine scheduler (Composables.kt).

The public contract says not to put callback data in a LaunchedEffect key just to relaunch ongoing work. A rapidly changing key turns data changes into cancellation and restart events. For work initiated by a click or another callback, use rememberCoroutineScope; the event starts a child job directly instead of pretending the event is composition identity (Effects.kt).

rememberCoroutineScope owns event jobs at a point

rememberCoroutineScope remembers one RememberedCoroutineScope for its call site and invokes getContext only while creating that value (Effects.kt). The scope’s context is assembled lazily with a child Job whose parent is the composition’s applying context. When the remembered scope is forgotten or abandoned, it cancels that child job. A supplied context may not already contain a parent Job; violating that precondition returns a failed scope rather than throwing from the composable call.

The ownership contrast is useful:

MechanismStarts workCancellation boundary
LaunchedEffect(keys)When the keyed owner is rememberedKey change or owner leaves composition
rememberCoroutineScope()Later, from an event callbackThe remembered scope leaves composition

Neither is a process-wide scope. Neither promises work survives the composition point that owns it. A Recomposer may remain open while a launched job is still active; the upstream recomposerRemainsOpenUntilEffectsJoin test demonstrates that the effect job participates in that composition/recomposer shutdown boundary (RecomposerTests.kt). That is ownership evidence, not a guarantee that every host waits in the same way.

Reproduce keyed restart and cancellation

The direct control is a keyed LaunchedEffect. At the pinned checkout, temporarily add launchedEffectKeyChangeCancelsAndRelaunches to compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/RecomposerTests.kt, beside recomposerRemainsOpenUntilEffectsJoin; add the kotlinx.coroutines.awaitCancellation import. Using its existing runTestUnconfined setup and withContext(TestMonotonicFrameClock(this)), create a Recomposer runner and Composition(UnitApplier(), recomposer), then set content like this:

var key by mutableStateOf(0)
val events = mutableListOf<String>()
composition.setContent {
    val currentKey = key
    LaunchedEffect(currentKey) {
        events += "start:$currentKey"
        try { awaitCancellation() } finally { events += "cancel:$currentKey" }
    }
}

After recomposer.awaitIdle(), assert events == ["start:0"]. Change only key to 1 inside Snapshot.withMutableSnapshot, await idle again, and assert events == ["start:0", "cancel:0", "start:1"]; dispose the composition, close the recomposer, and join its runner.

./gradlew :compose:runtime:runtime:desktopTest \
  --tests 'androidx.compose.runtime.RecomposerTests.launchedEffectKeyChangeCancelsAndRelaunches'

Control: keep the call site fixed and mutate only the key. Secondary comparison: pinned EffectsTests.testCommit4 changes a keyed DisposableEffect and should show dispose then replacement (EffectsTests.kt); it is shared-lifecycle context, not the principal LaunchedEffect evidence. Limits: this verifies key-triggered cancellation/relaunch, not dispatcher timing, failure recovery, or global-scope safety. This procedure was not executed for this lesson.

Misconceptions

  • LaunchedEffect is a global lifecycle coroutine.” Its remembered owner is scoped to a composition position and its keys.
  • “Changing a key only changes a parameter.” It changes remembered identity, so the old job is cancelled and a new job is launched.
  • rememberCoroutineScope launches work for me.” It returns an owned scope; an event callback must launch the child job.

Check yourself

A search query changes on every keystroke. Should the query be a LaunchedEffect key, a callback-launched child in a remembered scope, or neither? What cancellation behavior does each choice encode?

Evidence ledger

ClaimDirect evidenceStatus
LaunchedEffect remembers an implementation with the applying coroutine contextEffects.ktVersion-specific
Remembered effect callbacks launch and cancel the jobEffects.ktDurable Version-specific
Keys are ordinary remembered identity comparisonsComposables.ktDurable Version-specific
rememberCoroutineScope creates a composition-owned child job and cancels it when forgottenEffects.kt and Effects.ktVersion-specific

Freshness

Refresh when the runtime changes the parent apply context, the remembered-scope cancellation path, key comparison semantics, or the LaunchedEffect guidance in the public contract. Do not expand this lesson into frame scheduling or movable/reusable content.

Finished this lesson?

Your progress stays only in this browser.