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.dataTaskPublisher we 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 AnyCancellable stored 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:

  1. Leaving a publisher alive after a view is torn down.
  2. 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 AnyCancellable in a published property (or a private variable) and cancel it in a onDisappear handler 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, and replaceError to 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!