I had coroutines working. The data loaded. The screen showed something. I felt like I finally understood async in Android.
Then I added a search bar that should filter results as you type.
I called my suspend function on every keystroke. Every single character triggered a fresh network call. The results flickered. Old responses arrived after newer ones. The UI was a mess.
That's when I realized — coroutines give you one value, once. But some data doesn't work that way. Some data is alive. It changes. It flows.
That's when I learned Flow. And then StateFlow. And the way data actually moves through an Android app finally made sense.
The Problem With suspend for Live Data
A suspend function is like ordering food. You place the order, you wait, you get your food. Done. One request, one result.
But what if you're tracking a live score? What if you want to react every time the database changes? What if the user is typing and you want to filter results in real time?
You don't want one answer. You want a stream of answers over time.
// One value, one time — then silence
suspend fun getPokemon(): Pokemon
// A new value every time something changes
fun getPokemonUpdates(): Flow<Pokemon>
That's the difference. suspend is a bottle of water. Flow is a pipe.
What Is a Flow?
A Flow is a stream of values that arrive over time. You create it, you collect it, and every time a new value is emitted — your code runs.
fun countDown(): Flow<Int> = flow {
emit(3)
delay(1000)
emit(2)
delay(1000)
emit(1)
}
emit() is how you push a value into the stream. Each emit is one item. The collector receives them one by one.
And here's the important part — a Flow does nothing until someone collects it. It's lazy. It just sits there, waiting.
viewModelScope.launch {
countDown().collect { value ->
println(value) // 3... 2... 1...
}
}
The moment you call collect, the flow starts running.
Flow Operators — Where It Gets Useful
This is the part I wish I'd paid attention to earlier.
You don't have to collect raw values. You can transform the stream before it ever reaches your UI — filter it, map it, reshape it.
repository.getPokemonList()
.filter { it.type == "fire" }
.map { it.name.uppercase() }
.collect { name ->
textView.text = name
}
Reads like a sentence. Each operator takes the stream, does something to it, and passes it along.
The ones you'll use most:
| Operator | What it does |
|---|---|
map |
Transform each value into something else |
filter |
Only let values through that match a condition |
onEach |
Do something with each value without changing it |
take(n) |
Stop after n values |
The Problem With Regular Flow in the UI
Here's something I did wrong for longer than I'd like to admit.
// In ViewModel
fun getPokemon(): Flow<Pokemon> = repository.getPokemon()
// In Activity
lifecycleScope.launch {
viewModel.getPokemon().collect { pokemon ->
textView.text = pokemon.name
}
}
Looks fine. It's not.
Every time the Activity collects this flow — a new one starts from scratch. New network call. New everything. And when the phone rotates? The collection stops, the flow restarts, and the last value is gone. The screen flashes.
You need something that holds the current value and gives it immediately to anyone who starts watching — even if they started watching late.
That's StateFlow.
StateFlow — Flow That Remembers
StateFlow is a flow with one extra superpower: it always holds the latest value in memory.
Think of a scoreboard at a football match. Whether you look at it the moment a goal is scored or ten minutes later — you see the current score. You don't wait for the next goal to know what's happening.
That's StateFlow. It always knows what the current value is, and it tells you immediately.
class PokemonViewModel : ViewModel() {
private val _pokemon = MutableStateFlow<Pokemon?>(null)
val pokemon: StateFlow<Pokemon?> = _pokemon
fun loadPokemon(name: String) {
viewModelScope.launch(Dispatchers.IO) {
val result = repository.getPokemon(name)
_pokemon.value = result
}
}
}
Notice — no emit(). StateFlow uses .value = to update. And it lives in the ViewModel, so it survives rotation.
The Private/Public Pattern (Please Don't Skip This)
You'll see this in every professional Android project:
private val _pokemon = MutableStateFlow<Pokemon?>(null) // you write to this
val pokemon: StateFlow<Pokemon?> = _pokemon // UI reads from this
The underscore one is private and mutable — only the ViewModel can change it. The public one is read-only — the UI can observe it but never modify it directly.
This isn't just style. It's a rule. The UI should never control the state. It just reacts to it.
Collecting StateFlow in the UI
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.pokemon.collect { pokemon ->
pokemon?.let { textView.text = it.name }
}
}
}
repeatOnLifecycle(STARTED) is the piece most tutorials skip. It automatically pauses collection when the app goes to background and resumes when it comes back. Without it, your flow keeps running even when the user can't see the screen — wasting battery, risking crashes.
Always use it when collecting in an Activity or Fragment.
Flow vs StateFlow — The Simple Version
| Flow | StateFlow | |
|---|---|---|
| Holds last value? | No | Yes |
| Starts automatically? | No — needs a collector | Yes — always running |
| Where it lives | Repository | ViewModel |
| Use it for | DB queries, data streams | UI state |
The rule I follow: data comes from the repository as Flow. Data goes to the UI as StateFlow.
// Repository — emits a stream
fun getPokemonList(): Flow<List<Pokemon>>
// ViewModel — holds the current state for the UI
private val _list = MutableStateFlow<List<Pokemon>>(emptyList())
val list: StateFlow<List<Pokemon>> = _list
init {
viewModelScope.launch {
repository.getPokemonList().collect { result ->
_list.value = result
}
}
}
The Full Picture
Repository
└── Flow<T> ← stream from data source, lazy
ViewModel
└── MutableStateFlow<T> ← holds current value, survives rotation
└── StateFlow<T> ← read-only, exposed to UI
UI
└── collect { } ← reacts to every change
Once I drew this out, everything clicked. The data flows in one direction — from the source, through the ViewModel, to the screen. Each layer has one job.
What I Wish I Knew Earlier
Flow and StateFlow aren't two separate tools that compete. They're two parts of the same pipeline.
Flow is for the journey. StateFlow is for the destination.
Once you think of it that way — your Repository emitting changes, your ViewModel holding the latest version of truth, your UI just reacting — the code writes itself.
And that flickering search bar I mentioned at the start? Fixed. One Flow, debounced, collected properly. No more race conditions. No more flashing.
Top comments (0)