Empty states, loading and error states
Every screen you write has four faces, and most beginners only design one. This lesson uses the real state handling in Pocket Notes and Focus Flow to cover the other three — nothing yet, still fetching, and something broke.
Three of your four screens are undesigned
Open Pocket Notes on a phone that has never run it. For a fraction of a second, and then permanently until the first note exists, you are looking at a screen you had to design on purpose — because there is nothing in the database to draw.
That screen exists in your app. So does the version where a search matches nothing. So does the moment before the database has answered. And so, eventually, does the version where something failed.
Every screen in every app has four faces:
| Face | When | What it must say |
|---|---|---|
| Loading | The answer has not arrived | Something is happening, hold on |
| Empty | The answer arrived, and it was nothing | What goes here, and how to add one |
| Content | The normal case | The actual content |
| Error | The answer never came | What broke, and a way to try again |
The first one you write is Content. The other three are what separates an app that feels finished from one that feels like homework.
Stand in front of a departure board at a railway station.
Trains listed: that is content. Working normally.
A board that says "No further departures tonight": nothing is running, and the board tells you so, so you stop waiting and go find a bus.
A board showing "Updating…": the feed is being fetched. You wait, because you know something is coming.
A board reading "Live information unavailable — please ask staff": broken, and honest about it, with a next step.
Now imagine the fourth board: completely blank, backlight on. Are there no trains? Is it loading? Is it broken? You stand there for ten minutes and then miss your train.
A blank screen is not neutral. It is the worst of the four, because it makes the person guess — and they will usually guess that your app is broken.
The state object already knows
You have the machinery for this. It is the object you have been building since Part 4. Here is Pocket Notes' — every field earns its place:
1data class NotesUiState(
2 val notes: List<Note> = emptyList(),
3 val query: String = "",
4 val total: Int = 0,
5 val loading: Boolean = true
6)notes is the filtered list you can see. total is how many notes exist in the database at all. loading starts true.
Those last two fields are not decoration. They are what let one screen tell three different stories apart:
total == 0— you have never written a note. Show the welcome.total > 0butnotesis empty — you have notes, this search matched none of them. Show a different message.loading == true— the database has not answered yet. Show neither.
Without total, a search for "zzz" and a fresh install look identical to the screen, and one of them gets the wrong message.
The whole decision, in one when
The bug this prevents
Take out !state.loading and the app still builds, still passes every test you would think to write, and looks fine on your phone because you have three notes.
Then someone with four hundred notes opens it. NotesUiState() starts with an empty list, so for the first frame — before Room answers — the screen renders "No notes yet. Tap the + button to write your first one."
That is the , and once you know the name you will see it in shipped apps constantly. It is a two-word fix and nobody notices you made it, which is what polish usually looks like.
"The list is empty" and "I have not been told anything yet" are different facts. Any time you find yourself checking isEmpty() on data that arrives asynchronously, stop and ask which of the two you actually mean.
What a good empty state contains
Focus Flow's stats screen has the same problem in a different shape: a bar chart of zero bars is a rectangle of nothing. Here is what it draws instead:
1Icon(
2 imageVector = Icons.Filled.Insights,
3 contentDescription = null,
4 tint = scheme.primary,
5 modifier = Modifier.size(48.dp),
6)
7
8Spacer(Modifier.size(16.dp))
9
10Text(
11 text = "Nothing to show yet",
12 style = MaterialTheme.typography.titleMedium,
13 color = scheme.onSurface,
14)
15
16Spacer(Modifier.size(6.dp))
17
18Text(
19 text = "Finish one round on the " +
20 "Timer tab and your first bar " +
21 "appears here.",
22 textAlign = TextAlign.Center,
23 style = MaterialTheme.typography.bodyMedium,
24 color = scheme.onSurfaceVariant,
25)Three parts, and every good has the same three:
- A symbol. Not decoration — it stops the screen reading as a failure. A picture of a chart says "this is where charts live".
- A headline that is not an apology. "Nothing to show yet" is a statement of fact. "No data available" sounds like an error. "Oops! Something's missing!" sounds like the app is embarrassed.
- One instruction, naming the actual button. Finish one round on the Timer tab. Not "get started" — which tab, and what to do when you are there.
That third one is where most empty states fail. The reader is at the exact moment of not knowing what your app is for. Answer that question, in one sentence, and point at the thing they should tap.
Focus Flow's Stats tab, before the first session is ever finished.
Loading: three honest choices
Pocket Notes' draws nothing at all, and that is a real design decision rather than an oversight.
Room reads from a local file. On a phone that answers in a few milliseconds — faster than one frame. A spinner that appears and disappears within 16 milliseconds is a flicker, and a flicker looks like a bug. Below roughly a fifth of a second, showing nothing is the calmest option.
When it is slower than that, you have two better tools.
A , for work of unknown length:
1Box(
2 modifier = Modifier.fillMaxSize(),
3 contentAlignment = Alignment.Center
4) {
5 CircularProgressIndicator()
6}A , when you know the shape of what is coming. Grey blocks where the content will be. It reads as "your list is on its way" rather than "something is happening somewhere":
1@Composable
2fun SkeletonCard() {
3 val colors = MaterialTheme.colorScheme
4 Column(
5 modifier = Modifier
6 .fillMaxWidth()
7 .clip(MaterialTheme.shapes.medium)
8 .background(colors.surfaceContainerLow)
9 .padding(14.dp)
10 ) {
11 Bone(0.45f, colors.surfaceContainerHighest)
12 Spacer(Modifier.height(8.dp))
13 Bone(0.85f, colors.surfaceContainerHighest)
14 }
15}
16
17@Composable
18private fun Bone(width: Float, color: Color) {
19 Box(
20 modifier = Modifier
21 .fillMaxWidth(width)
22 .height(10.dp)
23 .clip(RoundedCornerShape(5.dp))
24 .background(color)
25 )
26}Three of those stacked in a Column is a convincing "notes are loading". It costs about twenty lines and no dependency.
Whatever you pick, pick one. A spinner and a skeleton and a "Loading…" caption is three answers to the same question, and it makes a fast app feel slow.
Errors: be honest about how few you have
Here is something most tutorials will not tell you. None of your three apps talks to a network. There is no server to be down, no request to time out. Room and DataStore both read files on the phone itself, and those almost always work.
So the honest position is: your are thin, and that is a property of these apps rather than a virtue. Do not invent a fake failure to have something to draw.
There is exactly one real failure path already in the code, and it is worth reading:
1val settings: Flow<AppSettings> = store.data
2 .catch { e ->
3 if (e is IOException) {
4 emit(emptyPreferences())
5 } else {
6 throw e
7 }
8 }
9 .map { prefs -> /* ... */ }Reading the settings file can genuinely fail — a full disk, a file damaged by a crash mid-write. intercepts that, and instead of letting the die it emits an empty set of preferences, which map turns into the defaults. The user gets a 25-minute timer instead of a crash and never knows anything happened.
That is one legitimate error strategy: recover silently when a sensible default exists. The other is to say so and offer a way out:
1@Composable
2fun ErrorState(
3 message: String,
4 onRetry: () -> Unit
5) {
6 Column(
7 modifier = Modifier
8 .fillMaxSize()
9 .padding(40.dp),
10 horizontalAlignment =
11 Alignment.CenterHorizontally,
12 verticalArrangement = Arrangement.Center
13 ) {
14 Text(
15 text = message,
16 textAlign = TextAlign.Center,
17 style = MaterialTheme.typography.bodyLarge,
18 color = MaterialTheme.colorScheme.onSurface
19 )
20 Spacer(Modifier.height(16.dp))
21 Button(onClick = onRetry) {
22 Text("Try again")
23 }
24 }
25}Two rules for the message:
- Say what failed, in words the reader owns. "Could not open your notes" beats "SQLiteDatabaseCorruptException". They cannot act on a class name.
- Never blame the reader. "Invalid input" is a sentence about them. "That date is in the past" is a sentence about the data.
And the button matters more than the message. An error with no next step is a dead end; an error with Try again is a small inconvenience.
- Open Pocket Studio → Projects → Pocket Notes.
- Tap Editor and open
app/src/main/java/.../ui/NoteListScreen.kt. - Find the
whenblock near the bottom. Change!state.loading -> EmptyNotes()to justelse -> EmptyNotes(). - Tap Build, then Run ▶. Write three or four notes so the list is not empty.
- Close the app fully — swipe it away from your recent apps — then reopen it from the home screen. Watch the top of the screen carefully as it opens.
- You should catch "No notes yet" for a single frame before your notes appear. That is the flash of empty. If your phone is fast, kill and reopen it a few times; it is easier to see than to photograph.
- Put
!state.loading ->back and Run again. The flash is gone. - Now the other empty state: tap the search box and type
zzzz. You get a different screen — Nothing found, with your query quoted back and the advice to try a shorter word. That branch was chosen bytotal > 0. - Open Focus Flow, tap the Stats tab. If you have finished a session it shows the chart; if not, you are looking at the empty state from the mockup above.
when to produce a value — assigning it to something, or returning it — rather than as a statement. As an expression it must cover every possibility, because otherwise there would be no value on some paths.else, or keep it as a plain statement the way NoteListScreen does. Note that the version in this lesson compiles precisely because nothing uses its result.import androidx.compose.material3.CircularProgressIndicator. Check it is material3 and not material — the Material 2 one has the same name and slightly different colours.EmptyNotes() or Text(...) from an ordinary function — a common slip when you pull the state decision out into a helper called something like chooseFace(state).@Composable on the helper. A function that draws is a composable, even if it only contains a when.try/catch and emitted from the catch block. Flows forbid this because it hides where an error really came from..catch { } operator on the flow instead, exactly as SettingsRepository does. It is allowed to emit, and it only sees errors from upstream — which is what you wanted anyway.loading flag to your UI state, start it true, and set it false in the same place you build the real state. Then only draw the empty screen when loading is false.- Every screen has four faces: loading, empty, content, error. Writing only the content one is what makes an app feel unfinished.
- Keep enough in your to tell them apart. Pocket Notes uses
totalto separate "no notes" from "no search results", andloadingto know whether it has been told anything yet. - An empty list is not proof of emptiness until the data has arrived. Skipping that check gives you the on every launch.
- A good is a symbol, a plain headline, and one instruction naming the button to tap.
- Under ~200ms, draw nothing. Longer than that, use a for unknown work or a when you know the shape of what is coming.
- Recover silently where a sensible default exists — that is what does in
SettingsRepository. Otherwise say what failed in plain words and give them a Try again button. - Next: accessibility — turning on TalkBack and finding out what your three apps actually sound like to somebody who cannot see them.