Compose Side Effects Without the Foot-Guns

AndroidJetpack ComposeKotlin
Share on LinkedIn

Every side-effect bug I've debugged in Compose comes down to one of three mistakes: running an effect with the wrong key so it either never refreshes or thrashes, capturing a stale value in a long-lived effect, or launching a coroutine from the wrong place so it leaks or fires on every recomposition. Compose's side-effect APIs exist precisely to make effects behave predictably against the recomposition lifecycle — but only if you match the API to the situation. Get the mental model right and this stops being scary.

The core idea: composable functions must be pure and can re-run any number of times, so anything that touches the outside world — starting a network call, subscribing to a callback, showing a snackbar — has to be wrapped in an effect handler that Compose controls. Here's each one and when it's the right tool.

LaunchedEffect: coroutines driven by composition

LaunchedEffect runs a suspend block when it enters composition and cancels it when it leaves. Its key(s) decide its lifecycle: change a key and Compose cancels the old coroutine and launches a new one.

LaunchedEffect(userId) {
    // Cancelled + restarted whenever userId changes.
    val profile = repo.loadProfile(userId)
    state = profile
}

The key is the whole game. LaunchedEffect(userId) reloads when the user changes — correct for data loading. LaunchedEffect(Unit) runs once for the composable's lifetime and never restarts — correct for a one-time animation or an initial event. The two classic bugs: keying on Unit when you needed it to react to a changing input (data never refreshes), or keying on something that changes every recomposition (effect restarts constantly, re-firing your network call). If you find yourself passing a freshly-allocated object as a key, that's the bug.

rememberUpdatedState: current values inside a stable effect

Sometimes you want an effect that runs once but always calls the latest callback. The naive version captures the callback at launch and calls a stale one forever:

@Composable
fun AutoDismiss(onTimeout: () -> Unit) {
    val currentOnTimeout by rememberUpdatedState(onTimeout)
    LaunchedEffect(Unit) {           // deliberately runs once
        delay(5000)
        currentOnTimeout()           // calls the latest, not the captured one
    }
}

Without rememberUpdatedState, if the parent recomposes and passes a new onTimeout lambda, this effect would still call the one from five seconds ago. If instead you keyed the effect on onTimeout, the timer would reset every recomposition — also wrong. rememberUpdatedState threads the needle: stable key, fresh value. It's the fix for a whole category of "why is it calling the old callback" bugs.

rememberCoroutineScope: coroutines driven by events

LaunchedEffect is for composition-driven work. When a coroutine should start because the user did something — tapped a button, pulled to refresh — you need a scope you can launch into from a regular callback:

val scope = rememberCoroutineScope()
Button(onClick = {
    scope.launch { snackbarHostState.showSnackbar("Saved") }
}) { Text("Save") }

You cannot call LaunchedEffect from inside an onClick — it's a composable, not a function you invoke imperatively. rememberCoroutineScope gives you a scope bound to the composition's lifecycle, so coroutines launched from events are cancelled when the composable leaves. Reaching for LaunchedEffect where you meant rememberCoroutineScope (or vice versa) is the composition-vs-event confusion at the root of many effect bugs.

DisposableEffect: things that must be cleaned up

When an effect acquires a resource that needs releasing — a listener, a broadcast receiver, a sensor subscription — DisposableEffect gives you an onDispose block that runs when the effect leaves composition or its key changes:

DisposableEffect(lifecycleOwner) {
    val observer = LifecycleEventObserver { _, event -> handle(event) }
    lifecycleOwner.lifecycle.addObserver(observer)
    onDispose {
        lifecycleOwner.lifecycle.removeObserver(observer)
    }
}

The onDispose is mandatory and it's where leaks are prevented. Any time you register a callback with something that outlives the composable, this is the tool. Forgetting DisposableEffect and registering in a LaunchedEffect instead means your listener leaks because there's no symmetric cleanup path.

SideEffect: publish state to non-Compose code

SideEffect runs after every successful recomposition and is for pushing Compose state out to an object that isn't Compose-aware — analytics, a third-party controller that needs the current value:

SideEffect {
    analytics.setCurrentScreen(screenName)
}

It has no key and no coroutine; it just fires on each committed composition. Use it sparingly and only for cheap, idempotent publishing. It's the wrong tool for anything asynchronous — that's LaunchedEffect.

A quick decision guide

You want to… Use
Start suspend work when a value appears/changes LaunchedEffect(key)
Run once but always call the latest lambda LaunchedEffect(Unit) + rememberUpdatedState
Launch a coroutine from a click/event rememberCoroutineScope
Register something that needs cleanup DisposableEffect
Push Compose state to non-Compose code each frame SideEffect
Convert a callback/flow into Compose state produceState / collectAsStateWithLifecycle

That last row matters: for consuming a Flow, prefer collectAsStateWithLifecycle over hand-rolling a LaunchedEffect that collects, because it respects the lifecycle and stops collection in the background — the same concern as the broader Compose recomposition and lifecycle hygiene.

The rules I hold to

Keep composables pure; every world-touching action goes through an effect handler. Pick the key so the effect's lifecycle matches its meaning — restart when the thing it depends on changes, and no more often. Reach for rememberUpdatedState the moment a long-lived effect needs a value that changes. Use DisposableEffect for anything with a cleanup, and never register listeners in a plain LaunchedEffect. Follow those and the foot-guns — stale captures, thrashing effects, leaked listeners — mostly disappear.

Resources

Frequently asked questions

When should I use LaunchedEffect versus rememberCoroutineScope?

Use LaunchedEffect when a coroutine should start as a consequence of composition and be tied to a key, like loading data when an id appears or animating on first display. Use rememberCoroutineScope when you need to launch a coroutine in response to a user event such as a button click, from a non-composable callback. The rule of thumb: LaunchedEffect for composition-driven effects, rememberCoroutineScope for event-driven ones.

What does the key parameter of LaunchedEffect do?

The key controls the effect's lifecycle: when a key changes, Compose cancels the running coroutine and starts a fresh one, and when the effect leaves composition the coroutine is cancelled. Passing Unit or true means run once and never restart; passing a value like userId means restart whenever that value changes. Choosing the wrong key is the top cause of effects that either never refresh or restart far too often.

How do I avoid a stale value captured inside LaunchedEffect?

Wrap the changing value in rememberUpdatedState so the effect reads the latest value without restarting. A LaunchedEffect captures its lambdas and variables at launch, so a long-running effect keyed on something stable will keep calling an old callback. rememberUpdatedState keeps a reference that's updated on each recomposition, letting the effect see current values while its key stays fixed.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →