Networking & Async Data
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
@Publishedstate 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 aswitch, and kick off loading with.taskso it cancels automatically.