DevBits
DevBitsAndroid Handbook
Jetpack Compose

What is snapshotFlow?

Observe Compose-owned state as a Flow when work needs to react to changes instead of rendering them directly.

CMP-012Advanced~45 sec

What the interviewer is testing

The interviewer is usually testing whether you understand the difference between using state to describe the UI and reacting to state changes by doing some work.

Compose already knows how to observe its own state and update the screen. snapshotFlow matters when that same state needs to cross into coroutine and Flow code — for example, analytics, debouncing, or another reaction that should not become part of what the UI renders.

A strong answer also shows that you know when not to use it. If the value already comes from a Flow, keep using that Flow. If the UI simply needs a smaller value derived from Compose state, derivedStateOf is often the better fit.

❌ Common mistake

If they ask you to elaborate

Imagine a shopping app with a long product list.

The screen already keeps its scroll position in a LazyListState:

val listState = rememberLazyListState()

That state can tell Compose which item is currently the first one visible on screen:

listState.firstVisibleItemIndex

While the user scrolls, that value might change like this:

0 → 1 → 2 → 3 → 4 → 5 ...

If the screen only needs that value to decide what to draw, Compose can use the state directly.

Now imagine the product team asks for something different:

"Track when someone scrolls past the first product so we know they started exploring the list."

Nothing new needs to appear on screen. The app needs to listen to the scroll state and react to it.

That is a good fit for snapshotFlow:

LaunchedEffect(listState) {
    snapshotFlow {
        listState.firstVisibleItemIndex
    }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { hasScrolled -> hasScrolled }
        .collect {
            analytics.scrolledPastFirstItem()
        }
}

Read it from top to bottom:

  • snapshotFlow watches the Compose state read inside its block.
  • map turns item positions such as 0, 1, 2, 3 into a simple false or true.
  • distinctUntilChanged ignores repeated answers such as true, true, true.
  • filter keeps only the true case.
  • collect is where the analytics work actually happens.

So the list can move through many positions, while the analytics code only sees the change it cares about.

There are two APIs in that example because they have different jobs.

LaunchedEffect gives the collection a coroutine that lives with this part of the UI. snapshotFlow watches the Compose state inside that coroutine and sends changes through a normal Kotlin Flow.

That is different from doing this:

LaunchedEffect(listState.firstVisibleItemIndex) {
    analytics.trackScroll()
}

Here every new index becomes a new effect key, so Compose cancels the current effect and starts it again as the user scrolls.

With snapshotFlow, one effect can stay alive while the changing values move through the Flow.

One detail is worth keeping in mind: the block passed to snapshotFlow should only read state and calculate a value.

Keep the work outside it:

snapshotFlow {
    listState.firstVisibleItemIndex > 0
}.collect { hasScrolled ->
    if (hasScrolled) {
        analytics.scrolledPastFirstItem()
    }
}

That keeps the code easy to reason about: first observe the state, then react to what was emitted.

snapshotFlow is also observing state, not recording every event that ever happened. If every individual occurrence matters — a payment attempt, a message being sent, or a purchase completing — model those as events at the source instead of trying to rebuild them later from UI state.

Production thinking

A useful production example is a search screen.

Imagine the search field keeps its editing state in Compose:

val searchState = rememberTextFieldState()

As someone types, the text changes quickly:

a → an → and → andr → andro → android

The UI needs those changes immediately so the text field feels responsive. But the search request is different. We probably do not want to hit the backend for every key press.

This is where snapshotFlow can be a good bridge:

LaunchedEffect(searchState) {
    snapshotFlow {
        searchState.text.toString()
    }
        .map { query -> query.trim() }
        .distinctUntilChanged()
        .debounce(300)
        .collectLatest { query ->
            viewModel.search(query)
        }
}

The text still belongs to Compose, but now its changes can use normal Flow behaviour.

debounce(300) waits for a short pause instead of reacting to every character. collectLatest means that if a newer query arrives while work for an older one is still running, the older collection can be cancelled so the latest query wins.

Conceptually:

a → an → and → andr → andro → android

                      debounce

                      android

                       search

The important decision is not "this value changes a lot, so use snapshotFlow."

It is:

Compose owns this state, but another piece of work needs to react to its changes as a stream.

That distinction matters in an existing architecture too.

If a ViewModel already exposes the search query as a StateFlow, there is usually no reason to send it through Compose state and then wrap it in snapshotFlow just to get a Flow again:

StateFlow

Compose State

snapshotFlow

Flow

The Flow already exists. Work with it directly where the search logic belongs.

On the other hand, when the source genuinely is Compose-owned state — a state-based text field is one example — snapshotFlow gives the rest of the app a clean way to react without moving that state somewhere else just to observe it.

A useful code-review question is:

"Does Compose own this state, and do we genuinely need to react to it as a stream?"

If the answer to either part is no, snapshotFlow probably is not the tool the code needs.

Follow-up questions

What separates a senior answer

Key takeaways

Learn more

On this page