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

 LaunchedEffectDisposableEffect
Body typesuspend lambda (a coroutine)ordinary lambda — register something, then describe how to undo it
Ends byfinishing naturally, or being cancelledan explicit onDispose { } block you must write yourself
Typical useone-shot data loads, reacting to a changing value with suspend worklisteners, 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)
        }
    }
}
Note: If your key never changes for the composable's whole lifetime (like a stable 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.
Next up: Some effects don't fit either mold — they need to run on every successful recomposition, or turn a callback-based API into Compose State. That's SideEffect & produceState.