Chanzmao ʕ•ᴥ•ʔ Bear Blog

Keep UI-Only Logic in the UI with snapshotFlow

Not every piece of UI state belongs in your ViewModel. Scroll position, selection, and other UI-only state can stay in the UI. snapshotFlow lets you react to those changes while keeping your architecture simple and idiomatic.

When working with Jetpack Compose, it’s easy to fall into the habit of sending every state change to the ViewModel.

For example:

But does your ViewModel really need to know about all of these?

In many cases, the answer is no.

State that exists only to render the UI belongs in the UI.

When you need to perform side effects based on that state, snapshotFlow is the tool designed for the job.

UI state should stay in the UI

Consider a scroll state.

val listState = rememberLazyListState()

This state exists to render the UI.

Your Repository doesn’t need it.

Your domain layer doesn’t need it.

In most cases, your ViewModel doesn’t need to own it either.

Sometimes you only need a side effect

Although the scroll position itself is UI-only, you may still want to:

A common approach is to push the state into the ViewModel just to trigger these actions.

Conceptually, the flow looks like this:

LazyColumn 
  ↓ 
ViewModel 
  ↓ 
Flow 
  ↓ 
 UI

That’s often more architecture than the problem requires.

snapshotFlow keeps everything where it belongs

Compose provides a dedicated API for observing Compose state as a Flow.

LaunchedEffect(Unit) { 
    snapshotFlow { listState.firstVisibleItemIndex }
        .collect { index -> 
            analytics.logScroll(index) 
        } 
}

The UI state stays in the UI.

Only the side effect leaves the composable.

There’s no need to move temporary UI state into your ViewModel.

You still get the full power of Flow

Because snapshotFlow returns a regular Flow, you can use any Flow operator.

LaunchedEffect(Unit) { 
    snapshotFlow { searchQuery } 
        .debounce(300) 
        .distinctUntilChanged() 
        .collect(viewModel::search) 
}

This allows you to build reactive pipelines directly from Compose state without introducing unnecessary state holders.

What belongs in the ViewModel?

A simple rule of thumb is:

Keep in the UI Keep in the ViewModel
Scroll position Screen UI state
Focus state Business logic
Selected item Repository interactions
FAB visibility Persisted state
Drag state Application state

Temporary UI state usually doesn’t need to leave the composable.

snapshotFlow is about separation of responsibilities

snapshotFlow is often introduced as “the API that converts Compose State into a Flow.”

That’s true—but its real value goes beyond that.

It allows you to react to UI state changes without making your ViewModel responsible for UI-only details.

The responsibilities become much clearer:

Each layer stays focused on what it should do.

Final thoughts

Not every piece of state belongs in your ViewModel.

If a value exists only to render the UI—such as scroll position, focus, or selection—it should usually remain inside the composable.

When you need to react to those changes, snapshotFlow lets you perform side effects without introducing unnecessary architecture.

Keep UI-only logic in the UI.

That’s one of the principles that makes Jetpack Compose code simpler, more maintainable, and easier to reason about. And snapshotFlow is one of the APIs that makes it possible.

#Android #Flow #Jetpack Compose #Kotlin