← All courses

Networking & Async Data

🗓 May 31, 2026 ⏱ 2 min read

Where networking belongs

Composables/views should stay focused on display, so networking and logic go into an ObservableObject view model. The view observes the model’s published state and simply renders whatever state it’s in: loading, success or error.

A view model with explicit states

struct User: Identifiable, Decodable { let id: Int; let name: String }

@MainActor
class UsersViewModel: ObservableObject {
    enum State { case idle, loading, success([User]), failed(String) }
    @Published var state: State = .idle

    func load() async {
        state = .loading
        do {
            let url = URL(string: "https://api.example.com/users")!
            let (data, _) = try await URLSession.shared.data(from: url)
            let users = try JSONDecoder().decode([User].self, from: data)
            state = .success(users)
        } catch {
            state = .failed(error.localizedDescription)
        }
    }
}

@MainActor guarantees the published state is updated on the main thread, so the UI stays correct — no manual thread hopping.

Driving the UI from state

struct UsersView: View {
    @StateObject private var vm = UsersViewModel()

    var body: some View {
        Group {
            switch vm.state {
            case .idle, .loading:
                ProgressView()
            case .success(let users):
                List(users) { Text($0.name) }
            case .failed(let message):
                VStack {
                    Text(message)
                    Button("Retry") { Task { await vm.load() } }
                }
            }
        }
        .task { await vm.load() }   // runs when the view appears
    }
}

.task vs onAppear

Use the .task modifier to start async work when a view appears — it automatically cancels the work if the view disappears, preventing wasted requests. Prefer it over onAppear { Task { } } for async loading.

Modeling state as an enum is the secret

By representing the screen as one enum (idle/loading/success/failed), the switch is exhaustive: you can never forget to handle loading or errors, and the UI always reflects exactly one clear state. This pattern scales to every data screen.

Common mistakes

  • Putting networking directly in the view body.
  • Updating @Published state off the main thread (use @MainActor).
  • Ignoring the error and loading states, leaving the user staring at a blank screen.
Summary: Put async work in a @MainActor ObservableObject, model the screen as an enum of states, render it with a switch, and kick off loading with .task so it cancels automatically.