Compose Effects Are Just remember() With a Lifecycle Hook
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.
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.
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.