Skip to Content
Course content

83: Combine Publishers from URLSession

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you first start mixing URLSession with Combine, there's a temptation to stick with what you know. Most of us spent years writing completion handlers, so the natural instinct is to wrap that old-school dataTask in a Future or a PassthroughSubject. I've seen a lot of developers do this because it feels like a safe bridge between the imperative world and the reactive one.

The Manual Bridge

Imagine we're building a tool to fetch a list of public repositories for a GitHub user. The "naive" way looks something like this: you create a Future, call dataTask(with:completionHandler:), and manually call the promise's resolve or reject based on the response. It looks a bit like this:

func fetchReposNaive(for user: String) -> AnyPublisher<[Repo], Error> {
    Future { promise in
        let url = URL(string: "https://api.github.com/users/\(user)/repos")!
        URLSession.shared.dataTask(with: url) { data, response, error in
            if let error = error {
                promise(.failure(error))
                return
            }
            // Now you're stuck doing manual JSON decoding inside a closure...
            do {
                let repos = try JSONDecoder().decode([Repo].self, from: data!)
                promise(.success(repos))
            } catch {
                promise(.failure(error))
            }
        }.resume()
    }.eraseToAnyPublisher()
}

Here is the problem: you're fighting the framework. You've essentially just wrapped a callback in a fancy box. You're still managing the .resume() call manually, and you're nesting your decoding logic inside a closure, which defeats the whole purpose of Combine's linear, declarative pipelines. It's verbose, and it's where bugs—like forgetting to call the promise in one of the edge cases—tend to hide.

The Native Publisher Approach

Apple realized this was a pain point, which is why URLSession has a built-in publisher: dataTaskPublisher(for:). Instead of you managing the lifecycle of the task, the publisher handles it. The moment you subscribe, the network request starts. When the request finishes, it emits a tuple containing the data and the response.

Let's rewrite that GitHub fetcher. This time, we'll treat the network request as just another link in the chain:

func fetchRepos(for user: String) -> AnyPublisher<[Repo], Error> {
    let url = URL(string: "https://api.github.com/users/\(user)/repos")!
    
    return URLSession.shared.dataTaskPublisher(for: url)
        .map(\.data) // We only care about the Data, not the URLResponse
        .decode(type: [Repo].self, decoder: JSONDecoder())
        .eraseToAnyPublisher()
}

Notice how much "noise" disappeared. We aren't manually checking for nil data or calling .resume(). We're just describing the transformation: "Take the network output, grab the data, decode it into an array of Repos, and pass it along." It's much easier to reason about because it reads like a sentence.

Taming the Background Thread

There is one major catch I want to warn you about. dataTaskPublisher performs its work on a background thread. If you try to assign the result of this publisher directly to a @Published property that updates a UI element, your app will crash or throw a purple warning in Xcode. You're trying to update the UI from a background queue, which is a cardinal sin in iOS development.

To fix this, you need to explicitly tell Combine to move the result back to the main thread before it reaches the subscriber. I usually drop a .receive(on: DispatchQueue.main) right before the sink or the assign call. If you're wrapping this in a ViewModel, it looks like this:

service.fetchRepos(for: "apple")
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { _ in }, receiveValue: { self.repositories = $0 })
    .store(in: &cancellables)

By doing this, you keep the heavy lifting (the network request and the JSON decoding) off the main thread, but ensure the UI update happens safely. It's a clean separation of concerns that doesn't require you to nest DispatchQueue.main.async blocks inside your logic.




📋 Practical Task

Build a GitHub User Profile Fetcher

Create a small program that fetches a single GitHub user's profile data. You will need to define a User struct that conforms to Codable (at minimum, include login and public_repos as properties).

Requirements:

  • Use URLSession.shared.dataTaskPublisher(for:) to hit the https://api.github.com/users/{username} endpoint.
  • Use .map(\.data) to extract the raw data.
  • Use .decode(type:decoder:) to convert the JSON into your User struct.
  • Handle errors using .replaceError(with:) to provide a "Guest User" fallback object so the pipeline doesn't fail completely.
  • Print the user's login name to the console using .sink.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.