Pocket Notes 2 — ViewModel and StateFlow
Put two layers between the screen and the database. The repository owns the DAO, the ViewModel owns one state object, and a note saved anywhere appears on screen without a single line of code saying "refresh".
The line that is not there
By the end of this chapter, tapping a button will add a note and the list on screen will grow.
Nothing in the screen code will say so.
There is no refresh(). No reloadNotes(). No "after saving, fetch the list again". The save goes to SQLite, SQLite tells the table changed, Room pushes a new list down the , the rebuilds one state object, and Compose redraws the parts that differ.
That missing line is the point of the whole chapter. Every "why did the screen not update?" bug you have ever heard of is a missing refresh somewhere. You are about to build a shape where there is nothing to forget.
Think about a restaurant kitchen.
The store room is the database. Cold, locked, and slow to walk to.
Nobody wanders in. There is one kitchen porter whose entire job is fetching from the store room — the . When the chef wants tomatoes, the chef asks the porter. The chef never learns which shelf they are on, and on the day the restaurant starts getting deliveries from a second supplier, only the porter's job changes.
Then there is the pass — the shelf where finished plates sit under a warming lamp, waiting for a waiter. That is the . Its trick is that plates stay on the pass when a waiter's shift ends. A new waiter walks up and the food is already there. Nobody re-cooks it.
Rotating your phone is exactly a shift change. The screen is destroyed and a new one is built. The pass does not care.
One new dependency
viewModel() — the function that fetches a ViewModel from Compose — lives in its own artifact, separate from the lifecycle library you already have.
1 implementation(libs.androidx.lifecycle.runtime.ktx)
2 implementation(
3 libs.androidx.lifecycle.viewmodel.compose
4 )
5 implementation(libs.androidx.activity.compose)That middle line is wrapped for a reason you will meet again in this app: written on one line it is 61 characters, one over what the editor shows. Kotlin does not mind. Your eyes will thank you.
The porter: a repository
1class NotesRepository(private val dao: NoteDao) {
2
3 /** Live list. Room re-sends it on every change. */
4 val notes: Flow<List<Note>> = dao.observeAll()
5
6 suspend fun find(id: Long): Note? = dao.findById(id)
7
8 suspend fun save(note: Note): Long = dao.upsert(note)
9
10 suspend fun delete(note: Note) = dao.delete(note)
11}Ten lines, and it looks like it does nothing. Every function just calls the DAO function with almost the same name. Reasonable question: why does this file exist?
Three answers, in increasing order of how much you will care.
It renames things into your language. The DAO says upsert. That is a database word. The repository says save, which is what the app is actually doing. The ViewModel above it never learns the word "upsert" exists.
It is a wall. dao is private. Nothing above this class can reach the database at all, even by accident. Compare that with chapter 1, where the screen itself held a NoteDao — fine for one number, a slowly spreading mess for anything bigger.
It is where change lands. Suppose next year you add sync to a server. Notes now come from two places, need merging, need a cache. Every line of that goes in this file. The ViewModel keeps asking for notes and never notices.
Right now the repository is one line of forwarding per function. That is what correct architecture looks like on day one: slightly pointless, and exactly the right shape when it stops being pointless.
One object for the whole screen
1data class NotesUiState(
2 val notes: List<Note> = emptyList(),
3 val query: String = "",
4 val loading: Boolean = true
5)This is the : everything the notes screen needs in order to draw itself, in one object.
The alternative is three separate values — a list, a query string, a loading flag — each changing on its own. That sounds harmless and is not. With three values there are moments where two have updated and the third has not, and your screen renders a combination that was never true: notes showing while loading is still true, a spinner over a full list.
One object cannot do that. Every redraw gets one NotesUiState, and every field in it came from the same instant.
loading starts true because it is honest. Before the first list arrives from the database, the app genuinely does not know whether you have no notes or a hundred. Chapter 3 uses that to avoid flashing "No notes yet" at somebody who has four hundred.
query is not used until chapter 6. It is here from the start because adding a field to this class later means touching every place that builds one.
The pass: the ViewModel
This is the centre of the app. Read it once, then step through it.
Why combine and not two separate observers
You could subscribe to the notes and to the query separately and update two pieces of state. People do. It goes wrong in a specific way: the two updates land in two different frames, and for one frame the screen shows the new list filtered by the old query.
combine collapses that. One block, one output, one moment.
Cold, hot, and why it matters
A plain is : it is a recipe, and it does nothing until somebody starts collecting. Collect it twice and the work happens twice.
A is : it exists whether anybody is watching or not, and it always has a current value you can read this instant.
Screens need hot. A screen is created at an unpredictable moment and must draw something on its very first frame. stateIn is the converter between the two, and viewModelScope is what keeps it running.
The screen, still deliberately plain
1@Composable
2fun NoteListScreen(vm: NotesViewModel) {
3
4 // collectAsState turns a Flow into Compose state.
5 val state by vm.uiState.collectAsState()
6 val type = MaterialTheme.typography
7 val colors = MaterialTheme.colorScheme is the last link in the chain. It subscribes to the StateFlow and hands back a Compose State, so every new value triggers of exactly the composables that read it. The by gives you state as a plain NotesUiState instead of a wrapper.
The body is one Column, a heading, a count, a button and a for loop:
1 Text(
2 text = "${state.notes.size} saved",
3 style = type.bodyMedium,
4 color = colors.onSurfaceVariant
5 )
6
7 Spacer(Modifier.height(20.dp))
8 Button(onClick = { vm.addTestNote() }) {
9 Text("Add a test note")
10 }
11 Spacer(Modifier.height(20.dp))
12
13 for (note in state.notes) {
14 Text(
15 text = note.title,
16 style = type.titleMedium,
17 color = colors.onSurface
18 )
19 Text(
20 text = note.body,
21 style = type.bodyMedium,
22 color = colors.onSurfaceVariant
23 )
24 Spacer(Modifier.height(16.dp))
25 }A plain for loop inside a Column, not a — that arrives in chapter 3. For now every note is built every redraw, which is fine for five and terrible for five hundred.
addTestNote() is scaffolding. It exists so this chapter has something to press, and chapter 4 deletes it the moment there is a real editor.
Wiring it up
1@Composable
2fun PocketNotesApp() {
3 // viewModel() creates it once and keeps it across
4 // rotation, because it belongs to the Activity.
5 val vm: NotesViewModel = viewModel()
6 NoteListScreen(vm)
7}viewModel() does something more careful than it looks. The first time it runs it builds a NotesViewModel. Every later call — including the one after you rotate the phone and the entire Activity is rebuilt — finds the same instance and hands it back. The ViewModel is stored against the Activity's lifecycle owner, not against the composable, and it survives a by design.
This also explains the crash in the Error Doctor below. viewModel() uses a default factory that knows exactly two shapes: a ViewModel with no constructor arguments, or an AndroidViewModel taking one Application. Ours is the second. Hand it a repository instead and it does not know where to get one.
End of chapter 2. Every word below the button came out of SQLite.
Three taps, three rows, and the count at the top agrees with the list because both read the same state.notes.
Four files: one new package member, three new classes, two rewrites.
- Open Pocket Studio → Projects → Pocket Notes.
- Open
app/build.gradle.ktsand add the wrappedlibs.androidx.lifecycle.viewmodel.composedependency. - In the project tree, open the
datapackage. Tap New → Kotlin File and createNotesRepository. - Long-press your package name, tap New → Package, and call it
ui. - Inside
ui, create three Kotlin files:NotesUiState,NotesViewModel,NoteListScreen. - Open
MainActivity.kt. DeleteDatabaseCheckScreenentirely and replace it withPocketNotesApp. - Tap Run.
- Press Add a test note three times. Watch the number at the top and the list below it move together.
- Now the experiment that proves the chapter: turn the phone sideways. The list is still there, and the database was never re-read — the ViewModel outlived the screen.
- Swipe the app away from recents and reopen it. The notes are still there too, but for a different reason: that time they came back off the disk.
Pocket Notes — end of chapter 2
A complete project. Unzip it, open it in Pocket Studio, and press Run.
viewModel() lives in lifecycle-viewmodel-compose, which is a different artifact from lifecycle-runtime-ktx — having one does not give you the other.import androidx.lifecycle.viewmodel.compose.viewModel to the file, and the wrapped implementation(libs.androidx.lifecycle.viewmodel.compose) to app/build.gradle.kts.count() may take time, and you called it from a function that has no way to wait.viewModelScope.launch { … }; in a composable it is LaunchedEffect(Unit) { … }.by on Compose state needs two extra operator functions, getValue and setValue, and each is a separate import that auto-complete often skips.import androidx.compose.runtime.getValue and import androidx.compose.runtime.setValue — or import the whole package with import androidx.compose.runtime.*, which is what these files do.AndroidViewModel taking exactly one Application. Yours takes something else — usually a repository.AndroidViewModel(app) and build the repository inside, as this app does: class NotesViewModel(app: Application) : AndroidViewModel(app). If you genuinely need constructor arguments later, that is what a is for.suspend from a DAO function, or called a blocking one directly from a tap handler. Room caught you before the user did.suspend back and call it inside viewModelScope.launch { … }. Never reach for .allowMainThreadQueries() on the database builder — it does not fix the freeze, it just downgrades a crash into a stutter nobody can debug.- The is the only class that touches the . It renames database words into app words, and it is where a future network or cache lands without any screen noticing.
- One object per screen. Every field in a redraw came from the same moment, so a half-updated screen cannot happen.
- merges the note list and the query into that one object, so there is exactly one rule for rebuilding it.
- turns a into a hot that always holds a current value, and
WhileSubscribed(5_000)keeps it alive across a rotation. - is where writes happen, off the , cancelled automatically when the dies.
- Next: the data is right and the screen is ugly. Chapter 3 turns the plain
forloop into a of cards with human-readable timestamps — and designs the empty screen properly.