Lesson 2
LaunchedEffect belongs to a keyed composition scope
Trace coroutine context, cancellation, rememberCoroutineScope ownership, and effect keys as lifetime identity.
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).
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] --> ForgetThis 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:
| Mechanism | Starts work | Cancellation boundary |
|---|---|---|
LaunchedEffect(keys) | When the keyed owner is remembered | Key change or owner leaves composition |
rememberCoroutineScope() | Later, from an event callback | The 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
- “
LaunchedEffectis 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.
- “
rememberCoroutineScopelaunches 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
| Claim | Direct evidence | Status |
|---|---|---|
LaunchedEffect remembers an implementation with the applying coroutine context | Effects.kt | Version-specific |
| Remembered effect callbacks launch and cancel the job | Effects.kt | Durable
Version-specific |
| Keys are ordinary remembered identity comparisons | Composables.kt | Durable
Version-specific |
rememberCoroutineScope creates a composition-owned child job and cancels it when forgotten | Effects.kt and Effects.kt | Version-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.