StateFlow and a single UI state object
Five loose values in a ViewModel will eventually contradict each other. Pack them into one object, publish it as a StateFlow, and the screen can never show a half-updated mixture again.
The screen that lies for one frame
Your ViewModel from Lesson 4.2 works. So imagine growing it, the way a real notes app grows:
1var notes by mutableStateOf(emptyList<Note>())
2 private set
3
4var loading by mutableStateOf(true)
5 private set
6
7var query by mutableStateOf("")
8 private setThree values, three separate writes. Which means there is a moment — one frame, maybe two — where notes has been filled in but loading is still true. The screen dutifully draws a spinner on top of a full list.
Then someone fixes it by setting loading = false first, and now there is a frame where the list is empty and the loading flag is off, so the screen flashes "No notes yet" at a user who has forty notes.
Nobody wrote a bug. The bug is, again, the shape: three facts that must always agree, stored in three places that can be written at three different times.
Think about a weather report.
Version one: three separate postcards. "Temperature 4°." "Wind 30 mph." "Snow." They arrive in whatever order the post decides. Read them as they land and you can easily end up believing it is 4° with yesterday's wind and last week's snow.
Version two: one postcard, with all three written on it and a time in the corner. You either have Tuesday's weather or you have Wednesday's. You can never have a mixture, because a mixture was never sent.
A object is the second postcard. One object, replaced whole, every time.
One object instead of three values
Here is the real state object from Pocket Notes:
1data class NotesUiState(
2 val notes: List<Note> = emptyList(),
3 val query: String = "",
4 val total: Int = 0,
5 val loading: Boolean = true
6)Four facts, one , every field a . You never edit one of these. You make a new one — which is exactly what copy() is for:
_state.value = _state.value.copy(loading = false)copy() produces a fresh NotesUiState with everything the same except the one field you named. The old object is untouched. Nothing can be observed half-changed, because there is no such thing as half-changed.
total deserves a look. It is the number of notes in the database, while notes is the filtered list you can see. Without both, the screen cannot tell "you have written no notes" apart from "your search matched nothing" — and those need two very different messages. Lesson 6.2 builds both.
Why not just use mutableStateOf?
mutableStateOf is a Compose type. Putting it in your ViewModel welds your logic to a UI toolkit: the class can no longer be used from anything that is not Compose, and a plain has to drag Compose in to read a number.
belongs to Kotlin's coroutines library instead. It knows nothing about screens. And that turns out to be the more useful half, because the data you want to show usually arrives as a already — Room hands you one, DataStore hands you one.
Flow, in one paragraph
A is a stream of values that arrive over time. A List is a bucket of values you already have; a Flow is a tap that drips new ones. You do not call a Flow, you collect it, and your code runs again each time something new comes out.
A plain Flow is : nothing happens until somebody collects it, and each collector starts the whole thing again. A StateFlow is : it is always running and always has a current value, which is exactly what a screen needs — there is never a moment where there is nothing to draw.
The private/public pair
The standard shape, straight out of Focus Flow's timer:
1private val _state = MutableStateFlow(TimerUiState())
2val state: StateFlow<TimerUiState> =
3 _state.asStateFlow()Two names for one thing. _state is a — it has a value you can write to — and it is private. state is the read-only view handed to the screen.
The leading underscore is a convention, not a rule: it means "this is the private backing thing, use the one without the underscore". You will see it in every Android codebase you ever open.
Because the screen only ever sees a StateFlow, it physically cannot write to it. Every change has to go through a function on the ViewModel, where the rules live.
Reading it from Compose
One line:
val state by vm.state.collectAsState() subscribes to the flow, turns each new value into Compose state, and unsubscribes when the composable leaves the screen. From there on state.leftMs and state.running behave like any other state you read — change one, and only the composables that read it redraw.
Turning a database Flow into a StateFlow
Room's queries are cold flows. A screen needs a hot one with a value ready to go. stateIn is the converter, and Pocket Notes uses it with combine to build its whole UI state in one expression:
That 5_000 is worth remembering. Use Lazily and the flow never stops. Use WhileSubscribed() with no number and a rotation counts as "nobody is watching" for a few milliseconds, so you re-read the database on every rotation. Five seconds is the number the Android team recommends, for exactly that reason.
copy() compares the fields, not their contents. If you put a MutableList in your state object and add to it in place, the new state object holds the same list, NotesUiState compares equal to the old one, and Compose skips the redraw entirely.
Keep state objects : List, not MutableList; val, not var. Build a new list with + or filter rather than editing the old one.
You will watch a StateFlow update a screen live.
- Open Pocket Studio, tap Projects, then Pocket Notes.
- Tap Editor and open
ui/NotesUiState.kt. Add one field at the end:val sortedNewestFirst: Boolean = true. - Open
ui/NotesViewModel.kt. Inside thecombineblock, addsortedNewestFirst = trueto theNotesUiState(...)call. - Tap Run. Nothing looks different — you have added a fact, not used it.
- Open
ui/NoteListScreen.ktand findval state by vm.uiState.collectAsState(). Under it addprintln("state: " + state.total). - Tap Run again, then tap +, type a note, and press Back.
- Open the Logcat tab. You will see a fresh
state:line every time the database changes — Room pushing a new list,combinerebuilding the state,collectAsStatewaking the screen. - Remove the two lines you added.
state.value = ... on the public StateFlow. Its value is read-only — which is the entire reason for exposing it._state instead: _state.value = _state.value.copy(...). If this is happening inside a composable, the change belongs in a ViewModel function, not in the screen..collect { } directly in a composable body. Collecting suspends, and drawing cannot suspend.collectAsState(). Inside a ViewModel use viewModelScope.launch { flow.collect { ... } }.asStateFlow() deliberately hands back the read-only view, and something is trying to write through it._state. If it is the screen, it should be calling a function on the ViewModel instead.launch { } inside a flow { } builder and emitted from it. A flow must emit from the coroutine that is collecting it.channelFlow { }, or build the value first and emit once.MutableList you edited in place — so the new object compares equal to the old one and Compose skips the work — or you are reading the flow without collecting it.val holding an immutable type, and check the screen really says by vm.uiState.collectAsState() rather than vm.uiState.value.- Separate values in a ViewModel drift apart. One object, replaced whole with
copy(), makes a half-updated screen impossible. - A is values arriving over time. is a flow that always has a current value — perfect for a screen.
- Keep private; expose the read-only
StateFlowwithasStateFlow(). - Read it in Compose with .
- turns a database flow into a one.
WhileSubscribed(5_000)survives a rotation without leaving the database open forever. - Everything inside the state object must be , or the redraw is silently skipped.
- Next: one screen is not an app. Navigation adds a second screen, a route to reach it, and a Back button that behaves.