Phase 4 · Data & PersistenceModule 20~28 min read

Kotlin Flow in the UI

Stream data reactively into Compose with Flow and StateFlow, collected safely.

What you'll learn

Some data doesn't arrive once — it streams. A database table changes, a location updates, a search query evolves keystroke by keystroke. Kotlin Flow models a stream of values over time, and it's how Room and DataStore hand you data. You've seen StateFlow already; now let's use flows properly.

By the end you'll be able to:

  • Distinguish a cold Flow from StateFlow/SharedFlow
  • Transform a flow with operators like map and filter
  • Combine several streams into one
  • Turn a flow into UI state and collect it safely

Cold flows vs StateFlow / SharedFlow

The three kinds you'll meet, and when each fits:

TypeNatureTypical use
Flow<T>Cold — runs only while collected; no current valueA stream from Room/DataStore or the network
StateFlow<T>Hot — always has a current value, emits on changeUI state exposed by a ViewModel
SharedFlow<T>Hot — no stored value; broadcasts to collectorsOne-off events (navigate, show snackbar)

A cold flow is lazy: nothing happens until someone collects it, and it starts fresh for each collector — like a recipe that only cooks when ordered. A hot flow (StateFlow/SharedFlow) is already running and shared among collectors — like a live radio broadcast.

Note

Rule of thumb: expose state the screen renders as StateFlow; use SharedFlow for one-time events that shouldn't replay on rotation (a "saved!" snackbar shouldn't fire again just because the screen was recreated).

Transforming flows

Flows compose like collections. Chain operators to build a pipeline — map to reshape each value, filter to drop some, distinctUntilChanged to skip duplicates. The transformation runs each time the source emits, keeping downstream always up to date.

Combining streams

combine merges several flows: whenever any source emits, it recomputes a result from the latest value of each. This is perfect for search — combine the live list of contacts with the current query to produce a filtered list — and stateIn converts that cold pipeline into a StateFlow the UI can read:

SearchViewModel.kt
class SearchViewModel(private val repo: ContactRepository) : ViewModel() {

    private val query = MutableStateFlow("")
    fun onQueryChange(q: String) { query.value = q }

    // A pipeline: raw contacts flow → filtered by the current query → UI state.
    val uiState: StateFlow<List<Contact>> =
        combine(repo.contacts, query) { contacts, q ->
            contacts.filter { it.name.contains(q, ignoreCase = true) }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList(),
        )
}

Tip

stateIn(..., SharingStarted.WhileSubscribed(5_000), initialValue) is the standard recipe for exposing a derived flow as UI state. WhileSubscribed(5000) keeps the upstream alive for 5 seconds after the last collector leaves — long enough to survive a rotation, short enough to stop work when the user really leaves.

Collecting safely

In Compose, never collect a flow with a raw loop. Convert it to state with collectAsStateWithLifecycle() — it collects only while the screen is on-screen and gives you a value that triggers recomposition on change:

SearchScreen.kt
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
    val contacts by viewModel.uiState.collectAsStateWithLifecycle()

    LazyColumn {
        items(contacts, key = { it.id }) { ContactRow(it) }
    }
}

That's the whole reactive loop: the database emits new contacts → the pipeline re-filters → the StateFlow updates → the list recomposes. You wrote no manual refresh anywhere.

Recap & quick check

Key takeaways

  • Flow models a stream of values over time — how Room and DataStore deliver data.
  • Cold Flow runs per-collector and has no current value; StateFlow is hot with a current value; SharedFlow broadcasts events.
  • Expose renderable state as StateFlow; use SharedFlow for one-off events that shouldn't replay.
  • Transform with map/filter and merge streams with combine; convert to state with stateIn.
  • In Compose, collect with collectAsStateWithLifecycle() — never a raw collect loop.

Quick check

1. What is a cold Flow?

2. Which type best fits UI state a screen renders?

3. You want a filtered list from a data stream plus a query stream. Use…

4. What does stateIn(..., WhileSubscribed(5000)) do?

5. Best way to fire a one-off 'Saved!' event that shouldn't replay on rotation?

You can stream data reactively. Now let's persist it — starting with simple settings via DataStore.