Composables must be pure, but real apps need to do things — call an API on screen open, show a snackbar, react to state. The effect APIs (LaunchedEffect, rememberCoroutineScope, DisposableEffect) do this safely.
A composable can recompose many times, so putting a side effect — a network call, a log, a subscription — directly in its body would run it repeatedly and unpredictably. The effect APIs exist to run side effects at controlled, lifecycle-aware moments and to clean them up. Understanding "never do effects inline; use an effect API" prevents a whole category of Compose bugs.
The trap: an effect placed directly in a composable body runs on EVERY
recomposition (which can be many times, unpredictably):
@Composable
fun Screen() {
loadData() // BUG: fires again and again on each recomposition
Text("...")
}
The effect APIs run side effects at CONTROLLED moments + clean them up:
LaunchedEffect(key) run a coroutine when it enters composition / key changes
rememberCoroutineScope launch coroutines from event callbacks (onClick)
DisposableEffect(key) set up + tear down (listeners, subscriptions)
produceState / snapshotFlow bridge non-Compose sources into Compose stateLaunchedEffect launches a coroutine when the composable enters composition, and re-launches it if a key you specify changes (cancelling the previous run). It is how you load data when a screen opens or re-fetch when an id changes — the coroutine is tied to the composable’s lifetime and cancels automatically when it leaves. This is the go-to for "do this async when the screen appears."
@Composable
fun UserProfile(userId: String) {
var user by remember { mutableStateOf<User?>(null) }
// runs when the composable enters, and RE-runs if userId changes
LaunchedEffect(userId) {
user = api.fetchUser(userId) // suspend call; cancelled if we leave
}
if (user == null) CircularProgressIndicator()
else Text(user!!.name)
}
// load-on-open + re-load-on-key-change, lifecycle-safe. The coroutine cancels
// automatically when the screen leaves composition (structured concurrency).LaunchedEffect runs during composition, but sometimes you need to start a coroutine in response to an event — a button click showing a snackbar. rememberCoroutineScope gives you a scope tied to the composable that you can launch into from callbacks. Use it when the trigger is a user event rather than the composable appearing.
@Composable
fun SaveButton(snackbarHostState: SnackbarHostState) {
val scope = rememberCoroutineScope() // scope tied to this composable
Button(onClick = {
// launch a coroutine in response to a CLICK (not during composition)
scope.launch {
saveData() // suspend
snackbarHostState.showSnackbar("Saved!") // also suspends
}
}) {
Text("Save")
}
}
// LaunchedEffect = react to composition/keys ; rememberCoroutineScope = react to events.When an effect needs cleanup — registering a listener, a sensor, a broadcast receiver — DisposableEffect pairs setup with an onDispose block that runs when the composable leaves or the key changes, preventing leaks. And to feed external reactive sources into Compose, collectAsState turns a Flow (e.g. from a ViewModel) into observable Compose state, connecting the reactive backend to the UI.
// setup + guaranteed teardown (avoids leaks)
@Composable
fun SensorReader(sensorManager: SensorManager) {
DisposableEffect(Unit) {
val listener = /* build a SensorEventListener */ makeListener()
sensorManager.registerListener(listener, /* ... */)
onDispose { sensorManager.unregisterListener(listener) } // cleanup
}
}
// bridge a ViewModel's Flow into Compose state — UI updates as data changes:
@Composable
fun MessagesScreen(viewModel: MessagesViewModel) {
val messages by viewModel.messages.collectAsState() // Flow -> Compose state
MessageList(messages)
}
// collectAsState is the seam between the reactive backend and the Compose UI.