Pocket Studio Academy
HomePart 66.2

Empty states, loading and error states

Full course10 min read·4 questions

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:

FaceWhenWhat it must say
LoadingThe answer has not arrivedSomething is happening, hold on
EmptyThe answer arrived, and it was nothingWhat goes here, and how to add one
ContentThe normal caseThe actual content
ErrorThe answer never cameWhat 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.

Think of it like this

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:

ui/NotesUiState.ktkotlin
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 > 0 but notes is 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.

Careful

"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:

ui/stats/StatsScreen.ktkotlin
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:

  1. A symbol. Not decoration — it stops the screen reading as a failure. A picture of a chart says "this is where charts live".
  2. 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.
  3. 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.

9:41▲ ▮
Stats
Nothing to show yet
Finish one round on the Timer tab and your first bar appears here.
Timer
Stats
Settings

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:

a loading statekotlin
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":

ui/SkeletonCard.ktkotlin
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.

Tip

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:

data/SettingsRepository.ktkotlin
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:

ui/ErrorState.ktkotlin
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.

Try it in Pocket Studio
  1. Open Pocket StudioProjectsPocket Notes.
  2. Tap Editor and open app/src/main/java/.../ui/NoteListScreen.kt.
  3. Find the when block near the bottom. Change !state.loading -> EmptyNotes() to just else -> EmptyNotes().
  4. Tap Build, then Run ▶. Write three or four notes so the list is not empty.
  5. 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.
  6. 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.
  7. Put !state.loading -> back and Run again. The flash is gone.
  8. 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 by total > 0.
  9. 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.
Error Doctor5 common errors
e: 'when' expression must be exhaustive, add necessary 'else' branch
MeansYou used 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.
FixEither add an 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.
e: Unresolved reference: CircularProgressIndicator
MeansThe composable exists but has not been imported into this file.
FixAdd import androidx.compose.material3.CircularProgressIndicator. Check it is material3 and not material — the Material 2 one has the same name and slightly different colours.
e: @Composable invocations can only happen from the context of a @Composable function
MeansYou called 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).
FixPut @Composable on the helper. A function that draws is a composable, even if it only contains a when.
java.lang.IllegalStateException: Flow exception transparency is violated: Previous 'emit' call has thrown exception
MeansYou wrapped the inside of a flow in an ordinary try/catch and emitted from the catch block. Flows forbid this because it hides where an error really came from.
FixUse the .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.
The empty state flashes on screen every time the app opens
MeansThe screen is treating "the list is empty" as proof that there is nothing to show, when it actually means the database has not answered yet.
FixAdd a 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.
Recap
  • 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 total to separate "no notes" from "no search results", and loading to 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.