RizTech Academy logo
RizTech Academy
State and LifecycleLesson 5 of 535 min

Side effects: LaunchedEffect and friends

A composable describes what the screen should look like, and Compose runs it whenever it likes: once, or a hundred times during a scroll, as module 3's logs showed. That is fine for describing. It is not fine for doing: showing a snackbar, scrolling a list, registering a listener. Anything a composable does besides describing UI is a side effect, and if it sits in the body it happens on every run. Compose has a small set of functions that decide when such work runs and when it stops. This lesson adds the two things Mandi Bhav's screen needs to do, a "Clear filters" snackbar when nothing matches and a "Back to top" button, and breaks each one first.

The trap: launching work in the body

When the filter matches nothing, the farmer should be offered a one-tap way out. A snackbar with a "Clear filters" action is the Material pattern for it, and showing one is a suspend function, SnackbarHostState.showSnackbar, so it needs a coroutine. The obvious first attempt, in a scratch copy of PricesScreen:

val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }

// The trap: launching in the body
if (prices.isEmpty()) {
    Log.d("Effects", "body: launching a snackbar for \"$query\"")
    scope.launch { snackbarHostState.showSnackbar("Launched from the body: \"$query\"") }
}

It builds, and lint rejects it:

PricesScreen.kt:134: Error: Calls to launch should happen inside a LaunchedEffect and not composition [CoroutineCreationDuringComposition from androidx.compose.runtime]
        scope.launch { snackbarHostState.showSnackbar("Launched from the body: \"$query\"") }
              ~~~~~~

Run it anyway and type "xyz" into the search:

D Effects : body: launching a snackbar for "xy"
D Effects : body: launching a snackbar for "xyz"

Two keystrokes that matched nothing, two recompositions, two coroutines, two snackbars queued one after the other. Type a long word that matches nothing and the user watches a queue of snackbars play out for the next minute. The body runs whenever Compose recomposes, and nothing in the body should start something that outlives that run.

LaunchedEffect: work tied to a key

LaunchedEffect(key) { … } runs its block in a coroutine when the composable enters the composition, and then:

What happens What the effect does
The composable enters the composition Starts the block
It recomposes and the key is equal to last time Nothing; the running block carries on
It recomposes and the key changed Cancels the running block and starts it again
The composable leaves the composition Cancels the block

So the key says what the work depends on. Mandi Bhav's snackbar depends on one thing, whether there are no results, and that is the key. This is the version the app keeps, in PricesScreen, above the Scaffold:

val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }

// …the Back to top state, below…

// While nothing matches, offer to clear the filters. The effect restarts
// when noResults changes, and leaving it (results again) cancels it,
// which dismisses the snackbar.
val noResults = prices.isEmpty()
val currentOnClearFilters by rememberUpdatedState(onClearFilters)
LaunchedEffect(noResults) {
    if (noResults) {
        val result = snackbarHostState.showSnackbar(
            message = "No prices match these filters.",
            actionLabel = "Clear filters",
            duration = SnackbarDuration.Indefinite,
        )
        if (result == SnackbarResult.ActionPerformed) {
            currentOnClearFilters()
        }
    }
}

and the Scaffold gets a place to show it:

snackbarHost = { SnackbarHost(hostState = snackbarHostState) },

showSnackbar suspends until the snackbar goes away and returns why: the action was tapped, or it was dismissed. SnackbarDuration.Indefinite keeps it up until one of those happens, and the key is what makes that safe. On the emulator:

  1. Typing "lasalgaon" with "Onion" selected showed the Lasalgaon onion card and no snackbar.
  2. Adding an "x" made noResults true. The key changed, the effect started, and the snackbar "No prices match these filters." appeared with "Clear filters".
  3. Deleting the "x" made noResults false. The key changed again, so Compose cancelled the running effect, and cancelling showSnackbar dismissed the snackbar. The card came back with no snackbar on screen, and no code of yours dismissed it.
  4. Typing "x" again and tapping "Clear filters" emptied the field, selected "All" and brought back all nine prices.

Every keystroke in between recomposed the screen, and none restarted the effect, because true equals true.

The screen takes the action as a new parameter, onClearFilters: () -> Unit, after onCommodityClick, and PricesRoute passes viewModel::clearFilters. The filter belongs to the ViewModel, so clearing it is a ViewModel function:

fun clearFilters() {
    savedStateHandle[KEY_QUERY] = ""
    savedStateHandle[KEY_COMMODITY] = null
}

with a test beside the others in PricesViewModelTest:

@Test
fun clearFiltersShowsEveryPriceAgain() = runTest {
    val viewModel = PricesViewModel(SavedStateHandle())
    collectVisiblePrices(viewModel)
    viewModel.onQueryChange("vashi")
    viewModel.onCommodityClick("Onion")
    assertEquals(emptyList<Any>(), viewModel.visiblePrices.value)

    viewModel.clearFilters()

    assertEquals("", viewModel.query.value)
    assertNull(viewModel.selectedCommodity.value)
    assertEquals(SamplePrices.all, viewModel.visiblePrices.value)
}

The three previews of PricesScreen pass onClearFilters = {}. The inline "No prices match" text stays: the snackbar is the shortcut, the text is what TalkBack users and anybody who dismissed the snackbar still see.

rememberUpdatedState

The effect is keyed on noResults alone, so while it waits for a tap it keeps the lambdas it captured when it started. If the screen were recomposed with a different onClearFilters meanwhile, the effect would call the old one. rememberUpdatedState(onClearFilters) gives the effect a state that always holds the latest value, without restarting it. The alternative, adding onClearFilters to the keys, would restart the effect, and re-show the snackbar, whenever the caller passed a new lambda. Key on what should restart the work; read everything else through rememberUpdatedState.

The second trap: an effect runs when it enters, too

Here is another reasonable idea: when the filter changes, scroll back to the top so the farmer sees the first result. A LaunchedEffect keyed on the selected commodity looks right. In the scratch copy:

LaunchedEffect(selectedCommodity) {
    Log.d("Effects", "scroll-to-top effect: commodity=$selectedCommodity, scroll was ${scrollState.value}")
    scrollState.animateScrollTo(0)
}

Scroll half way down the list, then rotate:

D Effects : scroll-to-top effect: commodity=null, scroll was 0
D Effects : scroll-to-top effect: commodity=null, scroll was 1450

The first line is the app starting. The second is the rotation. The filter had not changed, but the new activity built a new composition, the effect entered it, and an effect runs when it enters, whatever its key. rememberScrollState had faithfully restored the position, 1,450 pixels down, and the effect threw it away: the landscape screen showed the top of the list. Process death does the same.

The general lesson: a LaunchedEffect reacts to state, not to events. "The filter is now Onion" is state, and it is still true after a rotation, so the effect runs again. "The user has tapped a chip" is an event, and it happened once. Work that should happen once per user action belongs in the action's handler, which is the next section.

For this particular idea there was a second finding. With the event version (scroll inside the chip's onClick), the chips scroll away with the cards: by the time the list is scrolled far enough for scrolling back to matter, the chips are off screen and cannot be tapped. On this screen the idea solves nothing, so Mandi Bhav does not ship it. Module 6's lazy list changes the layout, and the question is worth asking again then.

rememberCoroutineScope: work started by a tap

What Mandi Bhav does need is a way back to the search field from the bottom of a long list. scrollState.animateScrollTo(0) is a suspend function, and a click handler is not a coroutine, so the handler needs a scope to launch in. rememberCoroutineScope() returns one tied to the composition: it is remembered across recompositions, and cancelled when the composable leaves, so a scroll animation cannot outlive its screen. In the Scaffold:

floatingActionButton = {
    if (showBackToTop) {
        ExtendedFloatingActionButton(
            onClick = { scope.launch { scrollState.animateScrollTo(0) } },
        ) {
            Text("Back to top")
        }
    }
},

The difference from the trap at the top of the lesson is where launch is called. There it ran on every recomposition. Here it runs inside onClick, once per tap, which is exactly what lint's message asked for. rememberCoroutineScope is for event handlers; LaunchedEffect is for work that follows state.

The button is text only. The course adds the first icon in the networking module, and an ExtendedFloatingActionButton that says what it does needs none.

The column's bottom padding grows from 8 dp to 88 dp. Before that change, the button sat on top of "Max ₹7,300" on the last card even when scrolled to the very end. 88 dp is 56 dp of button, 16 dp of margin below it, and 16 dp of space above it:

.verticalScroll(scrollState)
// 88 dp at the bottom lets the last card scroll clear of the
// "Back to top" button (56 dp tall, 16 dp from the edge).
.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 88.dp),

verticalScroll(rememberScrollState()) becomes verticalScroll(scrollState): the scroll state is hoisted one step, from the modifier to PricesScreen, because the button now reads it too. That is lesson 2's lowest-common-ancestor rule, applied to UI element state.

The new names in PricesScreen.kt need these imports: LaunchedEffect, derivedStateOf, rememberCoroutineScope and rememberUpdatedState from androidx.compose.runtime; ExtendedFloatingActionButton, SnackbarDuration, SnackbarHost, SnackbarHostState and SnackbarResult from androidx.compose.material3; LocalDensity from androidx.compose.ui.platform; and kotlinx.coroutines.launch.

derivedStateOf: recompose when the answer changes

The button should appear only after the farmer has scrolled 400 dp. The direct way:

val showBackToTop = scrollState.value > backToTopAfterPx

It works, and it is expensive. scrollState.value changes on every frame of a scroll, and reading it in the body recomposes the body on every one of those frames. With a log line in PricesScreen's body, one swipe down and one back up logged:

207 lines of "PricesScreen body, showBackToTop=…"

The whole screen, every chip and card, recomposed 207 times to decide a yes-or-no question whose answer changed twice. derivedStateOf turns a fast-changing input into a state that only changes when its result changes:

// Recomposes this screen only when the answer flips, not on every
// pixel of scrolling.
val backToTopAfterPx = with(LocalDensity.current) { 400.dp.toPx() }
val showBackToTop by remember(backToTopAfterPx) {
    derivedStateOf { scrollState.value > backToTopAfterPx }
}

The same swipe down and up:

D Effects : PricesScreen body, showBackToTop=false
D Effects : PricesScreen body, showBackToTop=true
D Effects : PricesScreen body, showBackToTop=false

Three recompositions: the first composition, and the two moments the answer flipped. The block still runs on every scroll frame, but it is a comparison of two numbers, and only a changed Boolean reaches the body.

  • remember around it, or a new derived state is built on every recomposition. The threshold is its key, so a change of screen density rebuilds it.
  • LocalDensity converts 400 dp into pixels for this screen, because scrollState.value is in pixels.
  • What it is not for: a value that changes as often as its input. derivedStateOf { query.trim() } changes whenever query does, so it only adds bookkeeping. Use it when many input changes produce few output changes, as here.

DisposableEffect: anything you must undo

Some side effects are registrations: start listening, and stop when the screen goes. DisposableEffect is a LaunchedEffect with a compulsory clean-up block. In the scratch copy, a lifecycle observer that logs what the activity is doing:

val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
    val observer = LifecycleEventObserver { _, event ->
        Log.d("Effects", "lifecycle: $event")
    }
    lifecycleOwner.lifecycle.addObserver(observer)
    Log.d("Effects", "DisposableEffect: observer added")
    onDispose {
        lifecycleOwner.lifecycle.removeObserver(observer)
        Log.d("Effects", "DisposableEffect: observer removed")
    }
}

LocalLifecycleOwner is imported from androidx.lifecycle.compose, and LifecycleEventObserver from androidx.lifecycle. On launch, then a rotation:

D Effects : DisposableEffect: observer added
D Effects : lifecycle: ON_CREATE
D Effects : lifecycle: ON_START
D Effects : lifecycle: ON_RESUME
D Effects : lifecycle: ON_PAUSE
D Effects : lifecycle: ON_STOP
D Effects : lifecycle: ON_DESTROY
D Effects : DisposableEffect: observer removed
D Effects : DisposableEffect: observer added
D Effects : lifecycle: ON_CREATE
D Effects : lifecycle: ON_START
D Effects : lifecycle: ON_RESUME

Module 2's lifecycle, seen from inside a composable. A newly added observer is brought up to date with the events it missed, which is why ON_CREATE appears after "added". Then the old activity was destroyed, the effect left the composition, onDispose removed the observer, and the new composition added a new one. Leave out onDispose's removal and the destroyed activity's lifecycle keeps a reference to your observer, and through it to everything the observer captured: lesson 3's leak, in another shape. That is why DisposableEffect will not compile without an onDispose block.

Mandi Bhav ships no DisposableEffect yet; it registers nothing. The location module will, for location updates that must stop when the screen does. For the common lifecycle case the lifecycle-runtime-compose library already added has ready-made versions, LifecycleStartEffect and LifecycleResumeEffect, built on the same idea. Remove the observer from the scratch copy.

The effect functions, as a reference

Function Runs Stops Use it for
LaunchedEffect(key) { } On entering the composition, and again when a key changes On leaving, or when a key changes Suspend work that follows state: the no-results snackbar
rememberCoroutineScope() Whenever you launch in it, from an event handler When the composable leaves Suspend work started by a tap: Back to top
rememberUpdatedState(x) — — Reading the latest value inside a long-running effect without restarting it
DisposableEffect(key) { onDispose { } } On entering, and when a key changes Runs onDispose on leaving or a key change Registering and unregistering listeners and observers
derivedStateOf { } (inside remember) Recalculates when its inputs change — A state that changes far less often than its inputs
SideEffect { } After every successful recomposition — Pushing Compose state into non-Compose code; rare

Check your work

Why launch in the body showed two snackbars for "xy" and "xyz". Each keystroke recomposed the body, and each recomposition launched another coroutine.

What lint calls that mistake. CoroutineCreationDuringComposition, an error.

What a LaunchedEffect does when its key changes. Cancels the running block and starts it again.

How the snackbar disappeared when results came back without any dismiss code. The key changed, Compose cancelled the effect, and cancelling showSnackbar dismisses the snackbar.

Why onClearFilters is read through rememberUpdatedState and not used as a key. As a key, a new lambda would restart the effect and re-show the snackbar; read through rememberUpdatedState, the effect always calls the latest one without restarting.

Why the scroll-to-top effect lost the scroll position on rotation. An effect runs whenever it enters the composition, and rotation builds a new composition.

When to use rememberCoroutineScope instead of LaunchedEffect. For work started by an event, such as a tap.

What derivedStateOf saved here. 207 recompositions of the screen became 3.

What onDispose is for. Undoing whatever the effect registered, so nothing outlives the screen.

Practice 3. The snackbar comes back after the rotation. That is correct here: there are still no results, so the offer still applies. It would be wrong for a one-time message such as "Price alert saved", which is an event, and showing an event once and only once is part of what module 7's UI state lesson solves.

Practice 4. On the emulator: typing "xyz" showed no snackbar, because the effect had already run once at start-up, when there were results, and a key of Unit never changes. After the rotations the snackbar appeared, because the effect entered a new composition while nothing matched. Deleting "xyz" brought the onion prices back with the snackbar still showing over them, because nothing cancelled the effect. Wrong three times, from one key. The key is not decoration: it is the list of things the work depends on.

Practice

  1. Add clearFilters and its test, the onClearFilters parameter, the snackbar effect, the "Back to top" button with its derivedStateOf, and the bottom padding. Run the tests (18) and lint, then the app: produce the snackbar, make it vanish by fixing the query, and use "Clear filters".
  2. Reproduce the body-launch trap in a scratch copy, read the lint error, and count the snackbars.
  3. With no results showing, rotate the emulator. Predict whether the snackbar returns, then check, and decide whether that is right.
  4. Break it on purpose: change LaunchedEffect(noResults) to LaunchedEffect(Unit). Type "xyz" and watch for the snackbar. Then rotate twice, and then delete "xyz". Describe what the snackbar did at each step, and why.
  5. Replace derivedStateOf with the direct comparison, put a log line in the body, and count the recompositions for one swipe down and up. Then put it back.
  6. Harder: add the lifecycle-logging DisposableEffect to a scratch copy and compare its output for Home and return with module 2's activity log. Then delete the removeObserver line, rotate five times, and explain what each destroyed activity is now holding on to.

Official documentation

Next: module 6, Lists and Navigation.


That is module 5. You can now:

  • say what mutableStateOf and remember each do, and recognise the plain-var and missing-remember bugs on sight and in lint;
  • hoist state out of composables, keep one owner per piece of state, and preview a screen in any state it can be in;
  • move a screen's state into a ViewModel with StateFlow, collect it with collectAsStateWithLifecycle, and keep anything from the activity out of it;
  • make that state survive process death with SavedStateHandle, and decide what belongs in saved state and what must be reloaded;
  • test a ViewModel's rules and its saved-state behaviour on the JVM with kotlinx-coroutines-test;
  • run side effects with the right tool: LaunchedEffect for state, rememberCoroutineScope for events, DisposableEffect for registrations, derivedStateOf for fast-changing inputs.

Mandi Bhav now keeps Kavita's search through a rotation, a phone call and Android killing it in the background, offers to clear a filter that matches nothing, and takes a farmer back to the top of a long list. It still builds every card at once in a scrolling Column, and it is still one screen. Module 6, Lists and Navigation, replaces the Column with a lazy list that builds only the cards on screen, and adds a second screen to navigate to.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship