Tutorials › Launch Lab Notes › Chapter 9

Before the Data Arrives: Four States, and the Dash That Replaces Them

LabChapter 9 of the Launch Lab Notes24 minAugust 19, 2026Intermediate

What does a screen draw before the data arrives?

Most shipping UIs answer with a shrug: they only know how to draw the happy path. While the network is in flight, while the list comes back empty, while App Store Connect returns a 409 — the view that only checks for .loaded draws nothing useful. The bug does not look like a crash. It looks like a quiet dash.

So this bench puts that shrug next to a panel that refuses it.

Six frames of one ManifestInbox cycle: the left panel stays a dash while the right climbs through FETCHING with a live millisecond counter, then FAILED, then LOADED — both sides only match when the phase is finally .loaded.

One phase, two panels

Both columns read the same InboxPhase. That is the whole experiment. The fetch is fake — ManifestInbox.fetch sleeps, then returns — but the phase value is real shared state, and both panels re-render from it.

enum InboxPhase: Equatable {
  case loading(elapsedMs: Int)
  case empty
  case failed(String)
  case loaded([ManifestRow])
}

Four cases. Not two. A boolean isLoading cannot express "the server answered and there is nothing to show," and it cannot carry the error string a retry button needs. The phase is the UI contract.

Side-by-side panels mid-fetch. Left LOADED ONLY shows a dash and 'ignored loading 1093ms'. Right ALL FOUR shows a spinner, FETCHING, and a large 1,093 ms readout.

At 1,093 ms into a pinned loading run, the right panel is honest: spinner, FETCHING, the live counter. The left panel — the control — prints ignored loading 1093ms under a dash. Same phase. One branch.

The control: if case .loaded

/// The broken version. One `if case`, no else branch worth designing.
private struct LoadedOnlyPanel: View {
  let phase: InboxPhase
 
  var body: some View {
    Group {
      if case .loaded(let rows) = phase {
        ManifestList(rows: rows, tint: Lab.rose)
      } else {
        // Silence. No spinner, no empty copy, no error text.
        VStack(spacing: 8) {
          Text("—")
          Text("no .loaded yet")
          Text(phaseLabel) // "ignored loading…", "ignored empty", …
        }
      }
    }
  }
}
The control only looks wrong in three of the four states

When the phase finally becomes .loaded, both panels draw the same three rows. That is not a coincidence and it is not a bug in the demo — it is why happy-path-only UIs ship. Reviewers open the screen after the data is warm. The dash never appears in the screenshot they approve.

Empty is the one nobody designs

Empty state: left panel still a dash labelled ignored empty; right panel shows a tray icon, 0 pending, and EMPTY STATE. Footer reads last 790 ms.

The empty reply resolved in 790 ms. The right panel does not pretend this is still loading, and it does not reuse the loaded list chrome with zero rows. It says 0 pending — an empty inbox is a destination, not a missing spinner.

The left panel labels itself ignored empty. That is the whole product failure mode: a successful round trip that the UI has no vocabulary for.

Empty is not loading with patience

Loading means "we do not know yet." Empty means "we know, and the answer is zero." Collapsing them into one spinner trains users to wait forever for a list that will never grow. The bench forces the distinction by making empty a separate InboxPhase case with its own chrome.

Failed carries a string, or it is useless

Failed state: left panel dash ignored failed; right panel FAILED badge and ASC 409 · version already exists. Footer last 791 ms.

791 ms later the forced failure lands: ASC 409 · version already exists. The right panel keeps the message on screen — retry belongs here, next to the reason. The left panel still has a dash. A user facing that dash has nothing to report but "it didn't work."

case .failed:
  return .failed("ASC 409 · version already exists")

Throwing from async is how the modern concurrency model surfaces this — try await either yields a value or an error you must handle. Mapping that error into a phase case is what turns a compiler-checked throw into something a view can draw.

Loaded: both panels finally agree

Loaded state: both panels show 3 READY with Aurora Notes, Harbor Log, and Parcel Wire. Footer NOW LOADED, last 790 ms.

790 ms of fake latency, then three rows on both sides:

AppBuild
Aurora Notes3.2.0 · 418
Harbor Log1.4.1 · 92
Parcel Wire2.0.0 · 11

Rose on the left, mint on the right — same data. The control was never incapable of drawing a list. It was incapable of drawing anything else.

What went wrong while building this

The first capture plan recorded in -slowmo with an eight-to-twelve-second loading scenario at the front of the cycle. The filmstrip came back as six frames of spinner and nothing else. Empty, failed, and loaded never appeared in the strip — not because the code was wrong, but because the loading beat ate the entire recording window.

That is why ManifestInbox.fetch now branches:

case .loading:
  if pinnedLoading {
    seconds = LabLaunchOptions.slowMotion ? 12 : 8  // stills need time
  } else {
    seconds = LabLaunchOptions.slowMotion ? 2.2 : 1.1  // cycles must finish
  }

Pinned -mode loading stays in flight long enough to photograph 1,093 ms. The free-running cycle keeps loading short so a twelve-second recording can actually reach the other three states. The chapter only exists because the first filmstrip contradicted the plan.

Task on the main actor, with a generation token

Each scenario bump increments fetchGeneration. The ticker that writes loading(elapsedMs:) and the final phase assignment both bail if a newer fetch has started. Without that, a slow scenario finishing after a fast one can paint a stale empty over a fresh loaded list — a race the happy-path panel would also hide behind a dash.

The switch that earns the mint border

private struct AllFourPanel: View {
  let phase: InboxPhase
 
  var body: some View {
    switch phase {
    case .loading(let ms):
      // ProgressView + "FETCHING" + "\(ms) ms"
    case .empty:
      // tray + "0 pending" + EMPTY STATE
    case .failed(let message):
      // FAILED + the real string
    case .loaded(let rows):
      ManifestList(rows: rows, tint: Lab.mint)
    }
  }
}

Exhaustive switch is not style. It is the compiler refusing to let you ship a fifth state you forgot to draw — the same pressure async throws puts on the fetch side.

Mini-exercise

In LoadedOnlyPanel, change the else branch to always show ProgressView() regardless of phase. Run the empty scenario (-mode empty). You will see a spinner forever on a fetch that already finished in ~790 ms — proof that "always show loading chrome" is not a substitute for an empty case.

Challenges

  1. Drive the same two panels from a real URLSession call that can 404, and map HTTPURLResponse.statusCode into .failed without inventing a parallel errorMessage: String? beside a rows: [Row] array.
  2. Add a fifth phase — .stale(rows) — for cached rows shown while a refresh is in flight. Watch the exhaustive switch force every call site to decide what "stale" looks like.
  3. Cancel the in-flight Task when the bench disappears and assert the generation token prevents a late .loaded from painting after teardown.
  4. Replace the live millisecond label with a determinate progress value from AsyncSequence byte counts — keep the control panel unchanged and photograph the difference again.
  5. Write an XCUITest that fails if the LOADED ONLY panel ever exposes an accessibility element during .empty or .failed (it should stay silent; that silence is the bug).

Key Points

Next up: cancellation — two lists that fetch on appear, one wired with .task (cancels on disappear) and one with .onAppear { Task { … } } (does not). Navigate away and back, and only one work counter climbs.

SwiftUI
SwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.
Swift
Swift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.
Ship iOS
Shipping workflows for iOS apps.
📚 Go deeper with LIPAI WANG’s hands-on Udemy bootcampsBrowse all courses →
← Ch 8: The Interface You Can't SeeCh 10: Who Keeps Working→
SwiftUIUltimate SwiftUI SeriesSwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.SwiftUltimate Swift SeriesSwift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.Ship iOSShip iOS Apps SeriesShipping workflows for iOS apps: signing, TestFlight, App Store Connect, CI, and release hygiene.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access