Compose Side Effects Explained

UI › Compose Core

Compose interviewers use side effects to check whether you actually understand recomposition, not just whether you've memorized a list of function names. A composable function is expected to be pure: no direct writes to a database, no direct analytics calls, no mutating some object it doesn't own. The reason is that recomposition doesn't behave like a normal function call. Compose can skip a composable entirely if its inputs didn't change, run it far more often than you'd expect, execute composables in a different order than they appear in your code, or start a recomposition and later throw it away without finishing. Code that lives directly in a composable's body runs every time that unpredictable process runs it, however many times that turns out to be.

@Composable
fun Bad() {
    analytics.track("screen_view")   // could run 0, 1, or many times
    Text("Hello")
}

That's not a reason to avoid side effects altogether, screens genuinely need to load data, start timers, and register listeners. It's a reason Compose gives you a specific family of effect APIs, LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope, rememberUpdatedState, produceState, and snapshotFlow, each scoped to the composition's lifecycle in a different, well-defined way. Picking the right one, and knowing its restart and cleanup rules, is the actual skill being tested.

LaunchedEffect is the workhorse for effects that should be driven by composition itself. Give it one or more keys, and it launches a coroutine when the call site first enters composition.

Two things can end that coroutine. If the call site leaves composition, the coroutine is cancelled outright, with no restart. If instead a key you passed changes on a later recomposition, the running coroutine is also cancelled, but a new one is immediately launched using the new keys.

@Composable
fun SearchResults(query: String) {
    LaunchedEffect(query) {
        val results = fetchResults(query)
        render(results)
    }
}

Here query is the key. Every keystroke changes it, so every keystroke cancels whatever fetchResults call was still in flight from the previous keystroke and starts a fresh one, giving you 'latest wins' search behavior without writing any cancellation logic yourself.

Passing a constant key instead, LaunchedEffect(Unit) or LaunchedEffect(true), means the key can never change, so the effect never restarts on its own. It launches once when the call site enters composition and then just runs for as long as that call site stays composed. That's a deliberate, valid choice, you'll see it paired with another API shortly for exactly the situations where restarting would be wrong.

LaunchedEffect only works inside a composable scope, and an onClick lambda isn't one, you can't nest a LaunchedEffect inside it. For coroutines you want to start imperatively, in response to a button tap or similar event, use rememberCoroutineScope() instead. It hands you a plain CoroutineScope tied to the call site's position in composition, and you hold onto it, then call .launch { } on it whenever the event actually fires.

@Composable
fun MyScreen(snackbarHostState: SnackbarHostState) {
    val scope = rememberCoroutineScope()
    Button(onClick = {
        scope.launch {
            snackbarHostState.showSnackbar("Action completed")
        }
    }) { Text("Click me") }
}

The scope is still cancelled when its call site leaves composition, so you get the same lifecycle safety net as LaunchedEffect. What differs is when the coroutine starts: LaunchedEffect starts automatically, driven by composition entering or a key changing, while rememberCoroutineScope gives you something to launch from later, driven by whatever event you choose. That's the distinction interviewers are actually checking for when they ask you to compare the two: one is composition-driven, the other is event-driven but still composition-scoped.

Combine a constant-key LaunchedEffect with a lambda parameter and you hit a specific trap. The effect captures whatever value that parameter held at the moment it started, and because the key never changes, the effect never restarts to pick up a newer value either.

@Composable
fun LandingScreen(onTimeout: () -> Unit) {
    val currentOnTimeout by rememberUpdatedState(onTimeout)
    LaunchedEffect(Unit) {
        delay(3000)
        currentOnTimeout()
    }
}

rememberUpdatedState(onTimeout) wraps the parameter in a State that Compose keeps current across every recomposition, so currentOnTimeout always refers to whichever onTimeout the caller most recently passed in, not the one that existed when the effect launched. When the three seconds elapse, the effect calls whatever is current at that instant.

Restarting the effect instead, by adding onTimeout as a key, would be wrong here: a new lambda instance on every recomposition would cancel and relaunch the timer over and over, and it would never actually finish counting down. rememberUpdatedState fixes this precisely because it keeps the effect's key stable while still reading a fresh value.

DisposableEffect(keys) is for side effects that need explicit cleanup, registering a listener and later unregistering it, subscribing to a lifecycle and later unsubscribing. Its block must end with an onDispose { } call, leaving it out is a compile error, because Compose has no other way to know how to undo what you set up.

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

When a key changes, the order matters: the previous onDispose runs first, tearing down whatever was registered before, and only then does the block run again with the new key to set up the new registration. That ordering guarantees you're never running two registrations at once. When the call site leaves composition for good, only onDispose runs, with no new block after it, cleanup with nothing to replace it.

SideEffect { } is the odd one out, it takes no keys and has no cleanup. It simply runs its block after every successful recomposition. That makes it the right tool for publishing Compose-managed state to something Compose doesn't manage, an analytics SDK's current-user property, for example.

@Composable
fun UserScreen(user: User, sdk: AnalyticsSDK) {
    SideEffect {
        sdk.setCurrentUser(user.id, user.name)
    }
}

The word 'successful' matters. If a recomposition starts and gets cancelled or discarded partway through, SideEffect's block never runs for that attempt, so you never end up publishing state for a version of the UI that never actually took effect. Compare that to DisposableEffect from a moment ago, which exists for cleanup and restarts only on a key change, SideEffect has no keys at all and fires on every single successful pass.

Two more APIs bridge Compose and the outside world, in opposite directions.

produceState { } turns a non-Compose source, a Flow, LiveData, or a plain suspend load, into Compose State<T>. It launches a coroutine scoped to composition and lets you assign to value inside it, whatever you assign becomes the current state, and equal consecutive values are conflated rather than triggering redundant recomposition.

@Composable
fun UserProfile(userId: String, repo: UserRepository): State<User?> =
    produceState<User?>(initialValue = null, userId) {
        value = repo.getUser(userId)
    }

snapshotFlow { } goes the other way: it turns State reads into a cold Flow. On collection it runs its block, records which State objects got read inside it, and re-emits whenever any of them changes, again conflating equal consecutive results. Being cold means nothing runs until something actually collects it, and being a real Flow means you can chain ordinary operators onto Compose state.

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .filter { it > 0 }
        .distinctUntilChanged()
        .take(1)
        .collect { analytics.track("scrolled_past_first") }
}

That's snapshotFlow collected inside a LaunchedEffect, the usual pairing, since a Flow needs a coroutine to collect it. Reading listState.firstVisibleItemIndex directly in a composable's body would fire the analytics call on every recomposition instead, with none of those operators available.

produceState deserves one more detail: what cleanup looks like when the source you're bridging is callback-based rather than suspend, a location listener, for example, that you register and later have to unregister.

@Composable
fun locationState(manager: LocationManager): State<Location?> =
    produceState<Location?>(initialValue = null) {
        val listener = LocationListener { loc -> value = loc }
        manager.requestLocationUpdates(listener)
        awaitDispose {
            manager.removeUpdates(listener)
        }
    }

Because produceState's block runs inside a coroutine, cleanup can't use DisposableEffect's onDispose { }, that belongs to a different API. Instead you call awaitDispose { }, which suspends the coroutine until the producer leaves composition, and only then runs the cleanup lambda you gave it. It's the coroutine-shaped equivalent of onDispose, doing the same job, unregistering whatever you registered, just fitting into produceState's coroutine body instead of a plain block.

There's a lifecycle gap that a plain LaunchedEffect doesn't close on its own. If you collect a ViewModel's Flow inside a LaunchedEffect, the collection runs for as long as the composable stays in composition, including while the app is backgrounded, which wastes work and can process updates the user can't even see.

val uiState by viewModel.uiState.collectAsStateWithLifecycle()

That one-liner is the usual fix. Under the hood it ties collection to the Lifecycle's STARTED state, so it stops when the app backgrounds and resumes automatically when it comes back to the foreground. You can write the same thing out explicitly with repeatOnLifecycle:

val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(viewModel, lifecycle) {
    lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { /* update local state */ }
    }
}

repeatOnLifecycle cancels and relaunches the block every time the lifecycle drops below and then re-enters the given state, so collection genuinely stops while backgrounded rather than piling up work Compose won't render. Reach for collectAsStateWithLifecycle by default, it wraps this exact pattern, and drop to repeatOnLifecycle directly when you need to run more than a single collect inside the block.

Every API in this lesson answers the same underlying question: given a side effect, what should trigger it, and what should clean it up? Driven by composition entering or a key changing, with automatic cancellation, that's LaunchedEffect. Driven by an event like a click, but still needing a composition-scoped place to launch from, that's rememberCoroutineScope. Needing to read the latest value inside a long-lived effect without restarting it, that's rememberUpdatedState. Needing explicit setup and teardown, that's DisposableEffect and its mandatory onDispose. Running after every successful recomposition with no keys and no cleanup, that's SideEffect. Bridging a non-Compose source into State, that's produceState, with awaitDispose for the callback-based case. Bridging State back out into a Flow you can apply operators to, that's snapshotFlow. And collecting any of that safely against the app's foreground and background lifecycle is repeatOnLifecycle or collectAsStateWithLifecycle.

If an interviewer asks you to justify a choice, the answer that actually lands is never just the name of the API. It's the trigger and the cleanup: this runs when composition enters or this key changes, it's cancelled when the call site leaves, and here's what tears down when that happens. That's the difference between having memorized seven function names and actually understanding why recomposition makes them necessary in the first place.

Back to Compose Side Effects