In this guide
Almost every Android app you build is, at its core, a set of screens reacting to data that changes over time: an API response arrives, a Room database row updates, a search query is typed, a sensor reading ticks. The question is how that changing data reaches your UI — and Kotlin Flow is the answer modern Android development has settled on.
Flow is part of Kotlin Coroutines. It models an asynchronous stream of values: instead of asking for one result and getting a callback, you collect a stream that emits values whenever they are ready. StateFlow and SharedFlow are its two specialised variants — one for observable state, one for events. This guide walks through all three the way you will actually use them in a student app: Retrofit calls feeding a screen, a search box with debounce, and UI state that survives rotation.
The problem Flow solves
Before Flow, Android developers carried changing data around with callbacks, listeners, and eventually LiveData. Each approach has a cost:
- Callbacks nest and tangle. Two sequential API calls plus error handling turn into a pyramid that is hard to read and harder to test.
- LiveData works, but it is Android-specific (hard to use in pure-Kotlin layers), has no operators, and cannot easily express things like "emit the latest search results, but ignore keystrokes older than 300 ms."
- Manual state flags (
isLoading,hasError,data) scattered across an Activity are the classic source of bugs where the screen shows a spinner forever or crashes after rotation.
Flow gives you one uniform model: everything asynchronous is a stream you transform with operators and collect with lifecycle awareness. The same code works in your repository layer (plain Kotlin, testable on the JVM) and in your UI layer.
Cold streams: what a Flow actually is
A basic Flow is cold: nothing happens until someone collects it, and each collector gets its own independent stream. Think of it like a function that returns values over time rather than all at once.
// A cold flow: this code does not run until collected
fun countdown(): Flow<Int> = flow {
for (i in 3 downTo 1) {
delay(500)
emit(i)
}
}
// In a coroutine scope:
countdown().collect { value ->
println(value) // 3, 2, 1 — printed half a second apart
}
Three properties matter:
- Sequential by default. Values are emitted one at a time, in order. No threading surprises inside the builder.
- Cancellable. If the collecting coroutine is cancelled (screen destroyed, scope cleared), the flow stops. This is how Flow avoids the leaked-callback problem for free.
- Re-runnable. Collect the same flow twice and the builder runs twice. For a database query this is usually what you want; for an expensive network call it means you should share the result instead of re-fetching — which is where StateFlow comes in.
The common builders: flow { } for custom emission logic, list.asFlow() to turn a collection into a stream, callbackFlow { } to bridge callback-based APIs (location updates, sensor listeners) into Flow. For student apps, flow { } plus Retrofit's suspend functions cover nearly everything.
Operators: where Flow earns its keep
Operators transform streams without nested callbacks. The five you will use constantly:
map— transform each value:users.map { it.name }.filter— keep values matching a predicate.debounce(300)— wait until values stop arriving for 300 ms. This is the canonical search-box operator: the user types "r-o-b-o-t", and only the final settled query triggers a network call.combine— merge two flows, emitting whenever either changes. Useful for "show products filtered by the selected category AND the search text."flatMapLatest— switch to a new inner flow whenever the outer emits, cancelling the previous one. The classic use: each new search query cancels the in-flight request for the old query, so stale results never overwrite fresh ones.
Two more for robustness:
repository.getProjects()
.catch { e -> emit(emptyList()) } // recover from errors in the stream
.flowOn(Dispatchers.IO) // run upstream work off the main thread
.collect { projects -> adapter.submitList(projects) }
catch handles exceptions from upstream without crashing the collector, and flowOn moves the producer's work (network, database) to a background thread while collection stays on the main thread. A frequent student mistake is wrapping the whole collect in withContext(Dispatchers.IO) and then touching views from a background thread — flowOn is the correct tool.
Collecting safely: the lifecycle problem
A flow keeps emitting as long as its collector is alive. If you launch collection in lifecycleScope.launch { } inside an Activity, it runs while the activity exists — including when the app is in the background, burning battery and potentially updating a stopped UI.
The correct pattern is repeatOnLifecycle:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.projects.collect { projects ->
adapter.submitList(projects)
}
}
}
Collection starts when the lifecycle reaches STARTED and is cancelled when it drops below — then restarted on return. Upstream work (your repository's network call) is cancelled too, so nothing runs while the screen is invisible.
In Jetpack Compose it is simpler: val projects by viewModel.projects.collectAsStateWithLifecycle(). This is lifecycle-aware by default and is the recommended way to observe flows in composables. Never use plain collectAsState() for flows tied to screens — it keeps collecting in the background.
StateFlow: UI state that survives
StateFlow is a hot flow that always holds a current value. New collectors immediately receive the latest value, and it never completes on its own. It is the standard holder for UI state in a ViewModel:
class ProjectViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun load() {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
_uiState.value = UiState.Success(repository.getProjects())
} catch (e: Exception) {
_uiState.value = UiState.Error("Could not load projects")
}
}
}
}
The discipline matters more than the syntax:
- The
MutableStateFlowstays private; the UI sees only the read-onlyStateFlow. If the Activity can write state directly, your state logic will scatter within a week. - Model state as a single data class or sealed interface (
Loading/Success/Error), not three separate booleans. One object means impossible states (loading AND error simultaneously) cannot exist. stateInconverts a cold flow into a StateFlow with a sharing policy — useSharingStarted.WhileSubscribed(5000)so the upstream stops when nobody is watching, with a 5-second grace period across quick rotations.
SharedFlow: one-shot events done right
StateFlow replays its latest value to every new collector — perfect for state, wrong for events. A "show snackbar" or "navigate to details" event must fire once, not replay after rotation. The old hack was a SingleLiveEvent; the Flow answer is SharedFlow:
private val _events = MutableSharedFlow<UiEvent>()
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
// emitting from the ViewModel (suspending if no buffer room):
_events.emit(UiEvent.ShowSnackbar("Project saved"))
With default settings (replay = 0), an event emitted while nobody is collecting is lost — so give it a small buffer (extraBufferCapacity = 1) or emit from a scope that guarantees a collector. Rule of thumb: StateFlow for state, SharedFlow for events. If you find yourself resetting a StateFlow to null after handling it, you wanted a SharedFlow.
Mistakes students make most
- Collecting without lifecycle awareness.
lifecycleScope.launch { flow.collect { } }in an Activity keeps work running in the background. UserepeatOnLifecycleorcollectAsStateWithLifecycle. - Exposing MutableStateFlow publicly. The moment the UI layer can assign
_uiState.value, the ViewModel stops owning its state. - Using StateFlow for one-shot events. The snackbar reappears after rotation. Use SharedFlow.
- Catching exceptions in the wrong place. A
try/catcharoundcollectcatches downstream errors; upstream failures belong in the flow'scatchoperator or the repository. - Re-collecting expensive cold flows. Collecting the same network-backed flow from two screens fires two requests. Share it with
stateIn/shareIninstead. - Blocking the collector. Heavy work inside
collect { }delays every subsequent emission. Move it upstream withflowOnor into a separate coroutine.
Frequently asked questions
Should I still learn LiveData?
Understand what it is, because you will meet it in older codebases and tutorials, but write all new code with Flow. Google's own guidance has moved to Flow/StateFlow for new development, and Flow works in pure-Kotlin modules where LiveData cannot go.
Flow or RxJava?
For a student app, Flow. It is part of the Kotlin language's coroutine support, has no extra dependency, and covers everything a typical app needs. RxJava remains relevant mainly in large legacy codebases.
Can I use Flow without coroutines knowledge?
Not comfortably. Spend a day on suspend functions, scopes, and dispatchers first — Flow is built on those concepts, and most Flow bugs are really coroutine-scope bugs.
How do I test a ViewModel that exposes StateFlow?
Use the kotlinx-coroutines-test library: runTest with a StandardTestDispatcher, collect the StateFlow with Turbine (a tiny testing library) or manually, advance virtual time, and assert the emitted states. Because the repository layer is plain Kotlin, you can unit-test the whole stream without an emulator.
Does Flow replace Retrofit callbacks?
Retrofit's suspend functions already return single values without callbacks; Flow enters when you have streams — polling, WebSocket messages, database observation with Room (@Query can return Flow<List<T>> directly), or combined UI state.
Limitations
Flow is not free. Debugging a long operator chain is harder than debugging sequential code — stack traces pass through several operator layers, so keep chains short and name intermediate flows. Backpressure is mostly handled for you (suspending emitters), but a fast producer with a slow collector on SharedFlow will suspend the producer unless you size the buffer deliberately. Finally, Flow adds real conceptual overhead to trivial apps: a single-screen app with one button does not need StateFlow, sealed UI states, and event channels — plain function calls are fine until the app grows into the pattern.