DisposableEffect
LaunchedEffect is built for suspend work — it runs a coroutine that eventually finishes,
or gets cancelled automatically when its key changes or the composable leaves. But some effects
aren't coroutines at all, and don't "finish" on their own: registering a callback with an Android
system service, adding a broadcast receiver, or subscribing to a listener object. These need to be
explicitly torn down when the composable that started them goes away — otherwise
you leak the listener, and it keeps firing into a screen that no longer exists. That explicit
teardown step is exactly what DisposableEffect exists for.
LaunchedEffect vs. DisposableEffect
LaunchedEffect | DisposableEffect | |
|---|---|---|
| Body type | suspend lambda (a coroutine) | ordinary lambda — register something, then describe how to undo it |
| Ends by | finishing naturally, or being cancelled | an explicit onDispose { } block you must write yourself |
| Typical use | one-shot data loads, reacting to a changing value with suspend work | listeners, receivers, observers, anything with a matching register/unregister pair |
The Mandatory onDispose Block
A DisposableEffect takes the same kind of key(s) as LaunchedEffect, but its
body is not suspend — instead, it must end with an onDispose { } block. Compose calls
that block when the effect is about to leave composition, either because its key changed (tear down
the old one before setting up the new one) or because the composable itself was removed from the UI
entirely. Leaving out onDispose is a compile error — the API is deliberately designed so
you cannot forget the cleanup step.
@Composable
fun SomeScreen(listener: SomeListener) {
DisposableEffect(listener) {
// Setup: runs when this effect enters composition
listener.register()
onDispose {
// Cleanup: runs when the key changes, or this leaves composition
listener.unregister()
}
}
}
Worked Example: A Location Listener on RecipeBox's Add/Edit Screen
Say RecipeBox's Add/Edit Recipe screen offers to tag a recipe's photo with the location it was cooked. While that screen is visible, it needs a location listener actively running; the instant the user navigates away, that listener has to stop — leaving it running would waste battery and could try to update state on a screen that's no longer showing.
@Composable
fun AddEditRecipeScreen(
locationProvider: RecipeLocationProvider,
onLocationUpdate: (Location) -> Unit
) {
DisposableEffect(locationProvider) {
val listener = RecipeLocationProvider.Listener { location ->
onLocationUpdate(location)
}
// Setup — start listening for location updates the moment
// this screen enters composition
locationProvider.registerListener(listener)
onDispose {
// Cleanup — guaranteed to run when the user navigates away,
// or if locationProvider itself is ever swapped out
locationProvider.unregisterListener(listener)
}
}
// ...rest of the Add/Edit form (title, ingredients, steps, category)
}
Another Common Case: Broadcast Receivers
The same register/unregister pattern applies to a BroadcastReceiver — for example,
listening for connectivity changes so RecipeBox can warn the user before they try to sync recipe
photos with no network available:
@Composable
fun RecipeListScreen(context: Context, onConnectivityChanged: (Boolean) -> Unit) {
DisposableEffect(context) {
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
onConnectivityChanged(isNetworkAvailable(ctx))
}
}
val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
context.registerReceiver(receiver, filter)
onDispose {
context.unregisterReceiver(receiver)
}
}
}
context), the setup block runs once and onDispose runs once, on final exit — the same one-shot feel as LaunchedEffect(Unit), just for non-suspend registration APIs instead of coroutines.State. That's SideEffect & produceState.