DEV Community

Cover image for I Thought Coroutines Were Enough. (Flow, StateFlow, MutableStateFlow)
Aalaa Fahiem
Aalaa Fahiem

Posted on

I Thought Coroutines Were Enough. (Flow, StateFlow, MutableStateFlow)

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>
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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...
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)