Skip to main content

Compose side effects and effect keys

Why effects exist​

Composition can run repeatedly, be skipped, or be abandoned. Therefore this is unsafe:

@Composable
fun Profile(vm: ProfileViewModel) {
vm.load() // ❌ may run on every recomposition
}

An effect ties non-UI work to a defined Composition lifetime.

API decision table​

NeedAPI
Run suspend work; cancel/restart with keysLaunchedEffect
Launch from click callbackrememberCoroutineScope
Register listener and always unregisterDisposableEffect
Publish Compose state after successful compositionSideEffect
Convert callback/suspend source into Compose StateproduceState
Use latest lambda/value without restarting effectrememberUpdatedState
Turn snapshot reads into a FlowsnapshotFlow

Keys define identity​

LaunchedEffect(userId) {
viewModel.loadUser(userId)
}

When userId changes, the old coroutine is cancelled and a new one begins. LaunchedEffect(Unit) means one lifetime for that call site, not once forever.

❌ Wrong key captures stale ID:

LaunchedEffect(Unit) { viewModel.loadUser(userId) }

❌ Unstable key restarts continuously:

LaunchedEffect(SearchRequest(query)) { search(query) }

Use the stable primitive input query.

Latest callback without restarting timer​

@Composable
fun Splash(onTimeout: () -> Unit) {
val latestOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(2_000)
latestOnTimeout()
}
}

The timer keeps its lifetime; the invoked callback stays current after recomposition.

Register and dispose symmetrically​

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

If registration depends on another value, include it as a key or use rememberUpdatedState for the changing callback.

produceState bridges a source​

@Composable
fun rememberStockPrice(symbol: String): State<PriceUiState> =
produceState<PriceUiState>(PriceUiState.Loading, symbol) {
value = runCatching { repository.latest(symbol) }
.fold(PriceUiState::Ready, PriceUiState::Failed)
}

It is cancelled when removed or when symbol changes. Prefer existing lifecycle-aware Flow collection when the source is already a Flow.

SideEffect is not a background-work launcher​

SideEffect { analytics.setUserProperty("plan", plan.name) }

It runs after every successful composition of that scope. The operation must be quick and idempotent. Network requests belong outside it.

Architecture boundary

Effects coordinate UI lifecycle work. Durable uploads, sync, or guaranteed retry belong in the data layer and often WorkManager—not in a composable coroutine.

Sources and next steps​

Exercises​

  1. Choose an API for snackbar click, location listener, analytics property, and keyed network load.
  2. Explain what changing a LaunchedEffect key does.
  3. Fix an effect that must keep its timer but invoke the latest callback.