Chapter 4 — Loading, mutation & error states

One value, four states

// Chirp/Networking/Loadable.swift
enum Loadable<Value> {
    case idle                    // haven't started yet
    case loading                 // request in flight
    case loaded(Value)           // success, here's the data
    case failed(Error)           // request failed, here's why
}

The view model drives the state

// Chirp/Features/Feed/FeedViewModel.swift
import Foundation

@MainActor
@Observable
final class FeedViewModel {
    private(set) var state: Loadable<[Post]> = .idle

    private let client: APIClient
    init(client: APIClient) { self.client = client }

    func load() async {
        state = .loading                                   // 1. show the spinner
        do {
            let page = try await client.send(API.feed(page: 1))
            state = .loaded(page.items)                    // 2a. success → content
        } catch is CancellationError {
            // 2b. the view went away mid-request — not an error, just stop.
        } catch {
            state = .failed(error)                         // 2c. failure → error UI
        }
    }
}
  • @MainActor on the view model means all its state changes happen on the main thread, so SwiftUI
  • We catch CancellationError separately and do nothing. When a view disappears, SwiftUI cancels

The view renders one state

// Chirp/Features/Feed/FeedView.swift
import SwiftUI

struct FeedView: View {
    @State private var viewModel: FeedViewModel
    init(client: APIClient) { _viewModel = State(initialValue: FeedViewModel(client: client)) }

    var body: some View {
        Group {
            switch viewModel.state {
            case .idle, .loading:
                ProgressView("Loading feed…")

            case .loaded(let posts) where posts.isEmpty:
                ContentUnavailableView("No posts yet", systemImage: "bird",
                    description: Text("Follow someone to see their posts here."))

            case .loaded(let posts):
                List(posts) { post in PostRow(post: post) }

            case .failed(let error):
                ErrorStateView(error: error) { await viewModel.load() }   // with a Retry button
            }
        }
        .task { await viewModel.load() }        // runs when the view appears; cancels when it leaves
    }
}
flowchart LR Idle["idle"] -->|".task → load()"| Loading["loading (spinner)"] Loading -->|"success"| Loaded["loaded"] Loaded -->|"items empty"| Empty["empty state"] Loaded -->|"items present"| Content["list"] Loading -->|"failure"| Failed["failed (error + retry)"] Failed -->|"retry"| Loading

Turning errors into messages users understand

// Chirp/Networking/APIError+UserMessage.swift
extension Error {
    var userMessage: String {
        switch self {
        case let api as APIError:
            switch api {
            case .server(_, let reason, _): return reason       // the API's own message (Ch 3)
            case .decoding:                 return "We got an unexpected response. Please try again."
            case .transport, .invalidResponse:
                                            return "Can't reach Chirp. Check your connection."
            }
        case let urlError as URLError where urlError.code == .notConnectedToInternet:
            return "You appear to be offline."
        default:
            return "Something went wrong. Please try again."
        }
    }
}

Mutations are a different shape

Pattern A — an in-flight flag on the action

@MainActor @Observable
final class ComposeViewModel {
    var text = ""
    private(set) var isSubmitting = false
    var errorMessage: String?
    private let client: APIClient
    init(client: APIClient) { self.client = client }

    var canSubmit: Bool { !text.isEmpty && !isSubmitting }

    func submit() async -> Bool {
        isSubmitting = true; errorMessage = nil
        defer { isSubmitting = false }
        do { _ = try await client.send(API.createPost(text: text)); return true }
        catch { errorMessage = error.userMessage; return false }
    }
}

Pattern B — optimistic updates for instant feedback

func toggleLike(_ post: Post) async {
    // 1. Update the UI right now (optimistic).
    let wasLiked = likedByMe[post.id] ?? post.likedByMe
    likedByMe[post.id] = !wasLiked

    do {
        // 2. Fire the request in the background.
        if wasLiked { _ = try await client.send(API.unlike(postID: post.id)) }
        else        { _ = try await client.send(API.like(postID: post.id)) }
    } catch {
        // 3. It failed — roll back to the truth.
        likedByMe[post.id] = wasLiked
        errorMessage = error.userMessage
    }
}

Making the pattern reusable

struct AsyncContentView<Value, Content: View>: View {
    let state: Loadable<Value>
    let retry: () async -> Void
    @ViewBuilder let content: (Value) -> Content

    var body: some View {
        switch state {
        case .idle, .loading: ProgressView()
        case .loaded(let value): content(value)
        case .failed(let error): ErrorStateView(error: error, retry: retry)
        }
    }
}

// Usage:
AsyncContentView(state: viewModel.state, retry: { await viewModel.load() }) { posts in
    List(posts) { PostRow(post: $0) }
}

What we built

  • Modeled a load's lifecycle as a single Loadable enum (idle / loading / loaded / failed) —
  • Drove SwiftUI from it in an @Observable @MainActor view model, with .task for
  • Distinguished the empty state as a sub-case of loaded, and mapped errors to user messages
  • Handled mutations two ways: an in-flight flag for submits, and **optimistic updates with
  • Extracted a reusable AsyncContentView so every screen gets correct states for free.

Mental model to take away

  • A networked screen has one state at a time — model it as a Loadable enum, not scattered
  • Tie loads to the view with .task (free cancellation) and treat CancellationError as
  • Reads are pessimistic (spinner → content/error/empty); mutations use an in-flight flag, or an