跪拜 Guibai
← All articles
Kotlin

Compose Effects Are Just remember() With a Lifecycle Hook

By RockByte ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Misunderstanding Effects as magical restart blocks leads to wasted coroutine allocations, accidental re-executions, and lifecycle bugs. Knowing that a key change is an object swap — not a re-run — makes it obvious when to drop the coroutine, when to use rememberUpdatedState, and why LaunchedEffect(Unit) doesn't mean what most developers think it means.

Summary

LaunchedEffect, DisposableEffect, and rememberCoroutineScope all follow the same pattern: remember() places a RememberObserver into the Slot Table, and the real work happens inside onRemembered, onForgotten, and onAbandoned. A key change triggers a full object swap — the old instance gets forgotten (canceling its coroutine or running cleanup), and a fresh instance gets remembered (launching a new coroutine or re-acquiring resources). SideEffect is the outlier: it never enters the Slot Table, has no identity or key, and simply runs after every successful commit.

This architecture carries a hidden cost. LaunchedEffect always creates a CoroutineScope, a Job, and a coroutine, even when the block is a single synchronous call that never suspends. For key-driven synchronous side effects, a lighter RememberedEffect that invokes the block directly in onRemembered avoids the coroutine machinery entirely. The Recomposer supplies the parent Job and a MonotonicFrameClock through applyCoroutineContext, which is why LaunchedEffect coroutines can use withFrameNanos without extra wiring and get canceled automatically when the Composition is disposed.

Common mistakes include using LaunchedEffect(Unit) and assuming it runs once per screen (it runs once per entry into the Composition), placing rememberCoroutineScope().launch directly in the composable body (causing duplicate launches on every recomposition), and picking the wrong key — keys determine object identity, so lambdas or objects that change identity every recomposition cause needless Effect restarts.

Takeaways
LaunchedEffect, DisposableEffect, and rememberCoroutineScope all rely on remember() to store a RememberObserver; the Effect function itself does almost nothing.
A key change causes remember() to create a new RememberObserver and schedule the old one for removal — it is a full object swap, not a re-execution of the same block.
LaunchedEffectImpl always creates a CoroutineScope and a Job, even for a synchronous block that never suspends.
The Recomposer injects a parent Job and a MonotonicFrameClock into applyCoroutineContext, giving every LaunchedEffect coroutine automatic cancellation and frame-synced animation support.
SideEffect does not implement RememberObserver and never enters the Slot Table; it is recorded in a change list and runs after every successful commit.
DisposableEffect uses no coroutine and is the correct choice for paired register/unregister patterns tied to a key.
A coroutine-free RememberedEffect that calls the block directly in onRemembered avoids the overhead of scope creation, job management, and coroutine launch/cancel cycles.
LaunchedEffect(Unit) executes once per entry into the Composition at that call site, not once per screen or process lifetime.
rememberUpdatedState lets a long-running Effect read the latest value without restarting when that value changes.
Calling rememberCoroutineScope().launch directly in a composable body launches a new coroutine on every recomposition; use LaunchedEffect for composition-driven work instead.
Conclusions

Compose's Effect APIs are so thin that the real mental model isn't about Effects at all — it's about remember() driving object identity and lifecycle callbacks. Once you see that, the four APIs collapse into two patterns: RememberObserver-based (LaunchedEffect, DisposableEffect, rememberCoroutineScope) and change-list-based (SideEffect).

The fact that LaunchedEffect unconditionally creates a coroutine even for synchronous work is a deliberate design trade-off, not an oversight. It prioritizes API uniformity and safety over zero-overhead, but the cost becomes measurable when dozens of such Effects sit on a frequently recomposing screen.

DisposableEffect is underused relative to LaunchedEffect. Many listener-registration patterns that currently spin up a coroutine just to call register/unregister could drop the coroutine entirely and use DisposableEffect, which is both lighter and semantically more precise.

The onAbandoned callback exists to handle a narrow but real edge case: a Composition that builds a RememberObserver but never commits. Most developers never encounter it, but its presence explains why Effects don't leak resources when a frame is discarded mid-composition.

Concepts & terms
RememberObserver
A Compose Runtime interface with three callbacks — onRemembered, onForgotten, onAbandoned — that gives an object a defined position in the Composition lifecycle. Most Effect APIs wrap an implementation of this interface stored via remember().
Slot Table
Compose's internal data structure that stores the state and objects remembered across recompositions. remember() reads and writes to the Slot Table; the table is what persists values when a composable function is called again.
applyCoroutineContext
A CoroutineContext provided by the Recomposer that includes a parent Job and a MonotonicFrameClock. LaunchedEffect uses it to create its internal CoroutineScope, which is why Effect coroutines are automatically canceled with the Composition and can use frame-synchronized APIs.
LeftCompositionCancellationException
A specific CancellationException used by Compose to cancel coroutines when their associated Effect leaves the Composition. It allows downstream code to distinguish cancellation-due-to-lifecycle from other cancellation causes.
rememberUpdatedState
A Compose utility that holds a mutable reference to the latest value passed to it, without triggering recomposition or Effect restarts when that value changes. Used inside long-running LaunchedEffect(Unit) blocks to read fresh lambdas or state without restarting the coroutine.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗