Every ViewModel tutorial I read handed me the finished file: @HiltViewModel, a private MutableStateFlow, a public StateFlow, viewModelScope.launch, a sealed class. It compiled. I copied it. I understood nothing.
The problem with starting from finished code is that it looks arbitrary. Why two properties instead of one? Why init? Why start at Loading?
So I did it backwards. I threw the code away and asked: what problems is this thing actually solving? There are 8. Stack the 8 answers and the file writes itself — nothing in it is a style choice.
Here's the version I wish I had.
The 6-year-old version
Imagine a whiteboard on the wall of a room. Anyone in the room can read the whiteboard, but only you hold the marker. When you change what's written, everyone looking at it instantly sees the new thing — nobody has to keep asking "did it change yet?"
That whiteboard is StateFlow. The rule that only you hold the marker is why there are two properties instead of one. And the whiteboard hangs on the wall (the ViewModel), not on a person — so when someone leaves the room, the writing survives.
Now the real thing.
First: what can a screen actually be doing?
Before the ViewModel, one observation: a list screen is always in exactly one of three situations. It's loading, it succeeded, or it failed. That's it.
sealed interface PokemonListUiState {
object Loading : PokemonListUiState
data class Success(val pokemon: List<PokemonResult>) : PokemonListUiState
data class Error(val message: String) : PokemonListUiState
}
sealed means closed set — "these are the only 3, nothing else can join." The compiler knows the complete list, so when the screen does when (state), it forces you to handle all three. You can't forget the error case.
Why not an enum? Because each case carries different cargo: Success holds a list, Error holds a message, Loading holds nothing. Enums can't do that cleanly. And notice Loading is an object, not a data class — it carries no data, so one shared instance is enough.
Now, the 8 problems.
Problem 1: The screen is fragile
Rotate the phone and Android destroys and rebuilds your screen. If the screen held the data, it's gone — you'd refetch from the network on every rotation. You need something that outlives the screen.
Solution: a ViewModel. That is literally its entire job.
class PokemonListViewModel : ViewModel()
Problem 2: The ViewModel needs a repository — but shouldn't build one
Building one means building Retrofit, which means OkHttp, which means a connection pool… all to ask for a list of Pokémon. We just want to ask for the thing, not assemble it.
Solution: let Hilt hand it over.
@HiltViewModel
class PokemonListViewModel @Inject constructor(
private val repository: PokemonRepository
) : ViewModel()
Note it asks for PokemonRepository — the interface, not the implementation. The ViewModel shouldn't know or care whether that data came from the network or a local database.
Problem 3: The data arrives LATER — how does the screen find out?
The screen already drew itself while the network was still working. Two options: the screen keeps polling ("done yet? done yet?"), or the screen subscribes and gets told.
Solution: hold the state in something watchable.
MutableStateFlow<PokemonListUiState>(...)
Set .value and everyone watching is instantly notified. Push, not poll. That's the whiteboard.
Problem 4: But if the screen can watch it, can it also change it?
That would be a disaster. Any screen could write garbage: viewModel.uiState.value = Success(fakeList). State would mutate from anywhere in the app, and when a bug shows up you'd have to hunt every file that might have written to it.
Solution: expose reading, hide writing.
private val _uiState = MutableStateFlow<PokemonListUiState>(PokemonListUiState.Loading)
val uiState: StateFlow<PokemonListUiState> = _uiState.asStateFlow()
MutableStateFlow has a settable .value. asStateFlow() returns a read-only view of the same data — no setter exposed. Now there is exactly one place in the codebase where state can change: inside this ViewModel. That single source of truth is the real payoff — it's a debuggability feature.
This is the answer to "why two properties instead of one."
Problem 5: When should the fetch start?
The moment the user opens the screen. Not on a button press, not later.
Solution: run it the instant the ViewModel is born.
init { ... }
Problem 6: The repository function is suspend — you can't just call it
suspend functions only run inside a coroutine. But which coroutine? Pick a long-lived scope (like GlobalScope) and the coroutine keeps running after the user has left the screen — holding references, updating state for something that no longer exists. That's a memory leak.
Solution: use a scope whose life is tied to the ViewModel's.
viewModelScope.launch { ... }
Two words worth separating: scope = where the coroutine lives (viewModelScope, which Android auto-cancels when the ViewModel dies). builder = what starts it (launch). You don't create the scope; the library hands you one pre-wired to the right lifetime.
Problem 7: The network can fail
No wifi. Server down. Timeout.
Solution: try it, and if it explodes, catch it and show the failure instead of crashing.
try { ... } catch (e: Exception) { ... }
Problem 8: What does the user stare at before the answer arrives?
A blank screen? An empty list?
Solution: start the state at Loading, before anything happens.
MutableStateFlow<PokemonListUiState>(PokemonListUiState.Loading)
The user sees a spinner instantly, and it's the truth: "I'm working on it."
Stack the 8 and the file writes itself
@HiltViewModel
class PokemonListViewModel @Inject constructor(
private val repository: PokemonRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<PokemonListUiState>(PokemonListUiState.Loading)
val uiState: StateFlow<PokemonListUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
_uiState.value = try {
val response = repository.getPokemonList(limit = 20, offset = 0)
PokemonListUiState.Success(response.results)
} catch (e: Exception) {
PokemonListUiState.Error(e.message ?: "Something went wrong")
}
}
}
}
Read it as a sentence: survive the screen → ask for the repo → hold state watchably → hide the writing → start at Loading → fetch at birth → in a scope that dies with you → handle failure. Not one line is arbitrary.
Gotchas I hit
- "MutableStateFlow makes it survive rotation." Nope — I merged two separate jobs. The ViewModel survives (Problem 1). StateFlow notifies (Problem 3). They just happen to live in the same file.
-
LoadingvsSuccess(emptyList()). I thoughtLoadingexisted to prevent a crash. It doesn't — nothing would crash. It's about not lying to the user.Loadingmeans "I'm working."Success(emptyList())means "I finished and found zero Pokémon." Those are completely different claims, and one of them is false before the network answers. -
e.messageis nullable.Error(e.message)won't compile against aString. You need a fallback:e.message ?: "Something went wrong". - Exposing the mutable flow "just for now." The moment it's public, state can change from anywhere, and your single source of truth is gone. It never stays "just for now."
Interview-ready takeaways
Q: Why two properties — a private _uiState and a public uiState?
So the screen can read state but never write it. asStateFlow() exposes a read-only view of the same data, keeping the ViewModel the single place state can change. With one public MutableStateFlow, any file could mutate it and bugs become untraceable.
Q: Why viewModelScope.launch and not GlobalScope.launch?
viewModelScope is auto-cancelled when the ViewModel is destroyed. GlobalScope outlives the screen — the coroutine keeps running and holding references for a screen the user already left. That's a memory leak.
Q: Why start at Loading instead of Success(emptyList())?
Because you haven't succeeded yet — you haven't even asked. Success(emptyList()) tells the user "done, found nothing," which is a lie. Loading says "I'm working," which is true, and gives them a spinner instead of a fake-empty screen.
Q: Why a sealed interface for UiState instead of an enum or booleans?
Sealed = closed set, so when is exhaustive and you can't forget a state. Unlike an enum, each case carries its own data (Success → a list, Error → a message). Unlike isLoading/isError booleans, you can't represent impossible combinations like loading and error at once.
TL;DR
A ViewModel isn't a pile of ceremony — it's 8 answers stacked. Survive rotation (ViewModel), get dependencies handed to you (Hilt), make state watchable (StateFlow), keep the marker to yourself (private mutable + public read-only), start honest (Loading), fetch on birth (init), stay in a scope that dies with you (viewModelScope), and expect failure (try/catch).
Memorize the why of each. Look up the syntax — nobody remembers how to spell asStateFlow().
Top comments (0)