Compose Side Effects: LaunchedEffect Keys and the Restart You Didn't Want
LaunchedEffect's keys are the effect's lifecycle, not a cache hint. Five gotchas — unstable keys, lazy list items, stale lambdas, input-driven work and one-off events — and the mental model that prevents all of them.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — A LaunchedEffect's key list is the effect's identity: same keys means keep running, new
keys means cancel and start over. Five bugs follow from getting that wrong — keying on a freshly
constructed object (restarts every recomposition), LaunchedEffect(Unit) in a lazy list item (fires again
on every scroll-back), capturing a stale lambda (fix with rememberUpdatedState), using it for
input-driven work that belongs in rememberCoroutineScope, and collecting one-off events with it. The
question to ask before writing the parentheses: when should this cancel and start again?
The mental model
LaunchedEffect(key1, key2) { … } does exactly two things:
- When it enters composition, it launches the block in a coroutine scoped to that position.
- On every recomposition it compares the keys with
equals. Unchanged → the running coroutine is left alone. Changed → the coroutine is cancelled, and a new one starts.
When it leaves composition, the coroutine is cancelled.
That's the whole API, and every gotcha below is a consequence of it. The keys are not a performance hint or a memoisation detail — they are the statement of what this effect's life is tied to.
Gotcha 1: the unstable key
@Composable
fun Profile(id: String, repo: Repo) {
var user by remember { mutableStateOf<User?>(null) }
// wrong: a new User object every recomposition
LaunchedEffect(User(id)) {
user = repo.load(id)
}
}
User(id) constructs a new instance on every recomposition. Unless User is a data class whose
equals happens to match, the key differs every time, so the effect is cancelled and relaunched every
time — and since the effect writes state that triggers recomposition, you have an infinite loop.
In production this presents as "the API is being hammered" and gets investigated as a networking problem for an afternoon.
LaunchedEffect(id) { user = repo.load(id) } // key on the identity
Rule: keys should be primitives or stable values you already have. If you're constructing something inside the key list, that's the smell.
Gotcha 2: LaunchedEffect(Unit) inside a lazy list item
LazyColumn {
items(posts, key = { it.id }) { post ->
PostCard(post)
LaunchedEffect(Unit) {
analytics.log("post_impression", post.id) // fires far too often
}
}
}
LaunchedEffect(Unit) means once per entry into composition — not once. A lazy list disposes items
that scroll out of view and recomposes them when they return, so one post scrolled past four times logs
four impressions.
The same trap applies to a screen in a navigation backstack (pop back → the effect runs again) and to tabs in a pager.
If the event should happen once per item, key it and dedupe in a place that outlives composition:
LaunchedEffect(post.id) {
if (seenIds.add(post.id)) analytics.log("post_impression", post.id)
}
Impression tracking is genuinely subtle — visibility duration, partial visibility, screen off. The point
here is narrower: Unit does not mean "once", and a lot of analytics data is wrong because someone
assumed it did.
Gotcha 3: the stale lambda
@Composable
fun AutoDismiss(onDismiss: () -> Unit) {
LaunchedEffect(Unit) {
delay(3_000)
onDismiss() // the lambda from three seconds ago
}
}
The coroutine captured onDismiss when it started. If the parent recomposed with a new lambda — a very
common case, since lambdas capturing changing state are recreated — the effect calls the outdated one.
Depending on what it closes over, that ranges from a no-op to dismissing the wrong thing.
Adding onDismiss to the keys "fixes" it by restarting the three-second timer on every recomposition,
which is worse. The right tool:
@Composable
fun AutoDismiss(onDismiss: () -> Unit) {
val currentOnDismiss by rememberUpdatedState(onDismiss)
LaunchedEffect(Unit) {
delay(3_000)
currentOnDismiss()
}
}
rememberUpdatedState exists for exactly this shape: read the latest value inside a long-lived effect
without making that value part of the effect's identity. Any time you want a value to be current but
not to restart the effect, that's the answer.
Gotcha 4: using it for input-driven work
// wrong
var shouldSave by remember { mutableStateOf(false) }
LaunchedEffect(shouldSave) {
if (shouldSave) { repo.save(); shouldSave = false }
}
Button(onClick = { shouldSave = true }) { Text("Save") }
The boolean-to-trigger-an-effect pattern is the clearest signal you've reached for the wrong tool. It also breaks in ways that are hard to reason about — the flag is state, so it survives recomposition and can combine with configuration changes to fire twice or not at all.
Effects driven by composition (this screen appeared, this id changed) use LaunchedEffect. Effects
driven by input (a tap, a swipe) use rememberCoroutineScope:
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { repo.save() } }) { Text("Save") }
Better still, send it to the ViewModel and let viewModelScope own work that should survive a rotation —
a scope tied to composition dies with the composition, which is rarely what you want for a save.
Gotcha 5: one-off events
Navigation commands, snackbars, "copied to clipboard". Modelling these as state and reacting with a
LaunchedEffect gives you the classic pair of bugs: the event replays after a configuration change, or it
is swallowed while the collector is detached.
// ViewModel
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
// Composable
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is UiEvent.Navigate -> navController.navigate(event.route)
is UiEvent.Snackbar -> snackbarHostState.showSnackbar(event.message)
}
}
}
A Channel gives single-consumption semantics — each event is delivered exactly once — which is the actual
requirement. (SharedFlow with replay = 0 works too, and drops events with no collector; pick the one
whose failure mode you prefer.) For the collection itself, collectAsStateWithLifecycle and the
lifecycle-aware collectors are what stop a backgrounded screen from consuming a navigation command.
DisposableEffect and SideEffect, briefly
DisposableEffect follows the identical key rules, with the addition that onDispose runs before each
restart — so an unstable key there means register/unregister churn on a listener, every frame.
SideEffect has no keys at all: it runs after every successful recomposition. It's for publishing
Compose state to a non-Compose object, and it is not a place to start work.
The rule
The key list is the effect's identity — write down what its life is tied to, and nothing else. Before
you type the parentheses, answer one question: when should this cancel and start again? If the answer is
"when the id changes", key on the id. If it's "never, but it should see the newest callback", that's
rememberUpdatedState. If it's "when the user taps", it was never a LaunchedEffect.