← All topics/Architecture & design patterns

Question 8

Loading, empty, and error states across features

Every screen implements spinners and alerts differently, causing inconsistent UX and duplicate code. What architectural approach unifies this?

Follow-ups

  • How does this relate to accessibility?

Answer outline

Model every async screen with one shared state enum whose cases are idle, loading, loaded, empty, and failed.

That beats separate properties like an isLoading flag, optional data, and an errorMessage, which can disagree with each other. You end up with a spinner over stale data or an error beside a result.

Each feature view model drives its own transitions, and the app uses one shared renderer for spinners, empty states, error messages, and retry buttons. The treatment stays consistent and the code exists once.

Principles

  • Prefer one mutually exclusive state over several independent properties that can drift out of agreement.
  • Keep the loading state in the view model rather than scattered through the view.
  • Share one UI treatment for loading, empty, error, and retry across every feature.
  • Map technical errors to user-facing messages in one place, so features never hand-write alert text.
  • Drive accessibility labels and announcements from the same state switch as the visible UI.

The view model owns the transition, and LoadStateView renders every case the same way across features:

LoadState enum and shared rendering
enum LoadState<Value> {
    case idle
    case loading
    case loaded(Value)
    case empty
    case failed(Error)
}

@MainActor
@Observable
final class SearchViewModel {
    private(set) var loadState: LoadState<[SearchResult]> = .idle
    private let api: SearchFetching

    init(api: SearchFetching) { self.api = api }

    func load() async {
        loadState = .loading
        do {
            let results = try await api.fetchResults()
            loadState = results.isEmpty ? .empty : .loaded(results)
        } catch {
            loadState = .failed(error)
        }
    }
}

struct LoadStateView<Value, Content: View>: View {
    let state: LoadState<Value>
    @ViewBuilder let content: (Value) -> Content
    let onRetry: () -> Void

    var body: some View {
        switch state {
        case .idle, .loading:
            ProgressView()
        case .loaded(let value):
            content(value)
        case .empty:
            ContentUnavailableView("No results", systemImage: "magnifyingglass")
        case .failed:
            VStack {
                ContentUnavailableView("Something went wrong", systemImage: "wifi.slash")
                Button("Retry", action: onRetry)
            }
        }
    }
}