How do you model screen state so illegal combinations cannot exist: enum with payloads versus a bag of flags?

15.7.2 · L3 · Interview

Predict first

What do the flag model and the enum model allow, and how does the enum reducer move through a refresh, a failure and a late success?

struct FlagsState { var isLoading = false; var items: [String]? = nil; var error: String? = nil }
enum LoadState: Equatable {
    case idle
    case loading(previous: [String]?)
    case loaded([String])
    case failed(String, previous: [String]?)
}
enum Action { case refreshTapped, succeeded([String]), failed(String) }
func reduce(_ s: LoadState, _ a: Action) -> LoadState {
    switch (s, a) {
    case (.loading, .refreshTapped): return s
    case (.idle, .refreshTapped): return .loading(previous: nil)
    case (.loaded(let items), .refreshTapped): return .loading(previous: items)
    case (.failed(_, let prev), .refreshTapped): return .loading(previous: prev)
    case (.loading, .succeeded(let items)): return .loaded(items)
    case (.loading(let prev), .failed(let msg)): return .failed(msg, previous: prev)
    default: return s
    }
}
var bad = FlagsState(); bad.isLoading = true; bad.error = "offline"
print("flags allow loading with an error:", bad.isLoading && bad.error != nil)
print("flag combinations:", 2 * 2 * 2, "enum cases:", 4)
var s = LoadState.idle
for a in [Action.refreshTapped, .succeeded(["a", "b"]), .refreshTapped, .failed("offline"), .succeeded(["late"]), .refreshTapped] {
    s = reduce(s, a)
    print(s)
}
  • flags allow loading with an error: true / 8 combinations vs 4 cases / the enum goes loading(nil), loaded, loading(previous), failed(previous kept), stays failed on a late success, then loading again
  • The flag model forbids loading together with an error
  • The enum drops the old items when a refresh fails

Predict first

A new case is added (“failed”) but the view’s switch only handles three states. Does it compile?

enum LoadState { case idle, loading, loaded([String]), failed(String) }
func title(_ s: LoadState) -> String {
    switch s {
    case .idle: "Start"
    case .loading: "Loading"
    case .loaded(let items): "\(items.count) items"
    }
}
print(title(.idle))
  • No: the compiler reports that the switch must be exhaustive
  • Yes, with a warning
  • Yes, and the missing case crashes at run time

Mental model. A traffic light has three lamps but only a few legal combinations. Wiring each lamp to its own switch allows red and green together; wiring one selector to “red, amber, green” cannot.

A screen modelled as isLoading: Bool, items: [Item]? and error: String? has 2 × 2 × 2 = 8 combinations, and several are nonsense: loading and failed at once (the check builds exactly that), loaded with an error still showing. Every bug report of the form “the spinner and the error are both visible” is such a state. Model it as one enum whose cases are the situations the screen can really be in, with the data each situation needs attached: idle, loading(previous:), loaded([Item]), failed(message, previous:). Four cases, no impossible pairs, and the compiler checks the view: a switch over the state must be exhaustive, so adding a case (“empty”) makes every place that renders state fail to compile until it handles it (the second check).

The reducer then reads as a transition table over (state, action) pairs. In the check a refresh from loaded becomes loading(previous:) so the old list stays visible under a spinner; a failure becomes failed(…, previous:), keeping the stale data for a retry banner; a late success after the failure is ignored by the default branch; and a second tap while loading is a no-op. Guidelines: put data that only makes sense in a state inside that case, keep independent concerns as separate properties (a text field’s draft is not part of the load state), avoid a default in view switches so new cases are caught, and use an explicit Equatable so tests compare whole states. The trade-off is some verbosity for state that genuinely overlaps (use a small struct plus an enum for the part that varies), and the enum should match what the UI shows, not mirror the server.

Flags can express impossible states; an enum cannot, and carries the previous data where the UI needs it.

Exhaustiveness turns “forgot to render the new state” into a compile error.

Common traps

  • Adding a default case to the rendering switch. It silences exactly the compiler error that would remind you to handle the new state.
  • One giant enum for the whole screen. Independent concerns multiply the cases; split them into separate properties or child states.

They might follow up with

  • How do I keep old data visible while reloading? Carry previous in the loading and failed cases.
  • Is this the same as a state machine? Yes in spirit: the enum is the states, the reducer is the transition table.

Related questions

  • 15.7.1 In a reducer-style store, how do you stop a slow, stale response overwriting a newer one: cancellation…
  • 15.7.3 How do you compose reducers: child features inside a parent, scoping state with key paths and embedding…
  • 15.1.2 What is unidirectional data flow, and how does a reducer make state changes predictable and testable?
  • 15.4.1 How do you model errors in Swift: throws, Result, optionals and typed throws, and how do you translate them…

← In a reducer-style store, how do you stop a slow, stale response…   How do you compose reducers: child features inside a parent, scoping… →