Compose's declarative model is great until your UI needs to do something outside of composition — start a coroutine, register a listener, or run cleanup. That's where side-effect APIs come in, and picking the wrong one is a common source of bugs and leaks.
Use this when you need to run suspend work tied to a composable's lifecycle. It launches a coroutine when it enters composition and cancels it when it leaves, or when its keys change.
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
The key (userId here) controls re-execution — change it, and the old coroutine cancels in favor of a new one.
Use this for non-suspend setup that needs explicit cleanup, like registering a broadcast receiver or a sensor listener.
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> ... }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
The onDispose block is mandatory — forgetting it is the most common way to leak listeners in Compose screens.
Use this when you need to launch a coroutine from an event callback (like a button click) rather than from composition itself.
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch { viewModel.submit() }
}) { Text("Submit") }
Unlike LaunchedEffect, this scope is tied to the composable's lifecycle but doesn't auto-launch — you control exactly when work starts.
LaunchedEffectDisposableEffectrememberCoroutineScope