Clean Network Calls in SwiftUI with Combine Publishers
Why a Publisher‑Based Approach Matters
When I first started using SwiftUI, I relied on the classic URLSession.dataTask callbacks. The pattern was familiar, but it quickly became messy: nested closures, manual thread hopping, and a hard time cancelling requests when a view disappeared. A few years ago I switched to Combine publishers, and the difference was night and day.
The publisher model is declarative. You describe what you want—data, decoding, error handling—and let the framework decide how to get it. It also gives you built‑in backpressure, easy composition, and a natural fit for SwiftUI’s expectation of values flowing over time.
In practice, the biggest win is thread safety. By using receive(on:) you guarantee that any value emitted to your UI code lands on the main queue, eliminating subtle crashes and UI jank.
A Practical Example: Fetching Users in a SwiftUI View
Imagine we need to display a list of users from a REST endpoint. The view should load the data once, show a loading indicator while the request is in flight, and gracefully handle network errors.
Below is a self‑contained SwiftUI view that does exactly that using a Combine publisher.
import SwiftUI
import Combine
struct UserListView: View {
@State private var users: [User] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var cancellable: AnyCancellable?
var body: some View {
VStack {
if isLoading {
ProgressView("Loading...")
} else if let error = errorMessage {
Text(error)
.foregroundColor(.red)
} else {
List(users, id: \.id) { user in
VStack(alignment: .leading) {
Text(user.name)
.font(.headline)
Text(user.email)
.font(.subheadline)
.foregroundColor(.gray)
}
}
}
}
.onAppear(perform: loadUsers)
.onDisappear(perform: cancelLoading)
}
private func loadUsers() {
// 1. Guard against duplicate loads
cancelLoading()
isLoading = true
errorMessage = nil
// 2. Build the publisher chain
let url = URL(string: "https://api.example.com/users")!
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.receive(on: DispatchQueue.main) // emit on main thread
.map(\.data) // keep only the data
.decode(type: [User].self, decoder: JSONDecoder())
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
},
receiveValue: { [weak self] users in
self?.users = users
}
)
}
private func cancelLoading() {
cancellable?.cancel()
cancellable = nil
}
}
// Simple model conforming to Codable
struct User: Codable, Identifiable {
let id: UUID
let name: String
let email: String
}
Let’s walk through the key parts:
- Publisher chain. Starting from
URLSession.shared.dataTaskPublisherwe map the raw data and decode it into[User]. Each operator is chainable, making the flow easy to read. - Thread safety. The
receive(on: DispatchQueue.main)call ensures the decoded array reaches the UI on the main thread, which is a requirement for any SwiftUI updates. - Subscription management. The
AnyCancellablestored in a variable lets us cancel the network call when the view disappears, preventing unnecessary background work and possible retention cycles.
Because everything lives inside the view’s state, SwiftUI will automatically recompute the UI whenever users, isLoading, or errorMessage change.
Putting It All Together – Error Handling and Subscription Management
Robust production code needs more than a happy path. Two common pitfalls are:
- Leaving a publisher alive after a view is torn down.
- Silently swallowing network errors.
Both are addressed in the example. The cancelLoading helper is attached to onDisappear, guaranteeing that any in‑flight request is stopped. The completion handler uses pattern matching (case .failure(let error)) to surface a user‑friendly message.
If you need to retry on failure, you can simply pipe the publisher through retry(when:) or retry(3). For logging, wrap the error block in a guard that writes to your analytics service.
Another useful pattern is to abstract the network layer into an enum or a protocol, then use associatedtype to keep the view independent of the concrete implementation. That way you can swap out a mock publisher for testing without touching the UI code.
Key Takeaways
- Combine publishers give you a declarative, composable way to perform network operations.
- Always
receive(on: DispatchQueue.main)when you plan to update UI from a publisher. - Store the
AnyCancellablein a published property (or a private variable) and cancel it in aonDisappearhandler to avoid dangling requests. - Use pattern matching in the completion handler to differentiate between success and failure, ensuring errors are not silently ignored.
- Chain operators like
map,decode, andreplaceErrorto keep the view logic clean and testable.
Switching to a publisher‑based approach has reduced the amount of boilerplate I need to maintain, and it makes my SwiftUI views easier to reason about. If you’re still using callbacks for network work, give the pattern above a try—you’ll likely notice the same benefits in your own codebase.
Happy coding!