← All topics/Architecture & design patterns

Practical interview questions

Scenario-style prompts with sample answer outlines. Focus is on how you would design and reason in real codebases.

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

Use one shared state model, as a protocol, for async screens: idle, loading, loaded, empty, and failed.

This is better than separate booleans like isLoading, data, and errorMessage, because those can disagree with each other.

Each feature view model owns its own loading transition, but the app uses a shared renderer for spinners, empty states, errors, and retry buttons.

Principles

  • Prefer one mutually exclusive state over several booleans.
  • Keep loading state in the view model, not scattered through the view.
  • Share the UI treatment for loading, empty, error, and retry states.
  • Map technical errors into user-friendly messages in one place.
  • Accessibility should follow the same state switch as the visible UI.
`LoadState` + 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)
            }
        }
    }
}