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
}
}
}
@MainActoron the view model means all its state changes happen on the main thread, so SwiftUI- We catch
CancellationErrorseparately 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
Loadableenum (idle / loading / loaded / failed) — - Drove SwiftUI from it in an
@Observable@MainActorview model, with.taskfor - 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
AsyncContentViewso 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
Loadableenum, not scattered - Tie loads to the view with
.task(free cancellation) and treatCancellationErroras - Reads are pessimistic (spinner → content/error/empty); mutations use an in-flight flag, or an