Networking with Combine and SwiftUI

Getting Started

Published: January 17, 2022 - 6 min read

Not keeping the UI up to date across the different parts of an app can result in an infuriatingly bad user experience, and I am sure we all have at least one or two apps in mind that are notorious for this kind of behaviour.

Writing apps that keep the state in sync across the UI and the underlying data model has traditionally been a difficult task, and the development community has come up with plenty of approaches to address this challenge in more or less developer-friendly ways.

Reactive programming is one such approach, and SwiftUI’s reactive state management makes this a lot easier by introducing the notion of a source of truth that can be shared across your app using SwiftUI’s property wrappers such as @EnvironmentObject, @ObservedObject, and @StateObject.

This source of truth usually is your in-memory data model - but as we all know, no application exists in isolation. Most modern apps need to access the network (or other services) at some point, and this means introducing asynchronous behaviour to your app. There are plenty of ways to deal with asynchronous behaviour in our apps: delegate methods, callback handlers, Combine, and async/await, to name just a few.

In this series, we will look at how to use Combine in the context of SwiftUI to

  • access the network,
  • map data,
  • handle errors

… and deal with some advanced scenarios.

Let’s kick things off by looking into how to use Combine to fetch data from a server and map the result to a Swift struct.

How to fetch data using URLSession

Let’s assume we’re working on a sign up screen for an app, and one of the requirements is to check if the username the user chose is still available in our user database. This requires us to communicate with our authorization server. Here is a request that shows how we might try to find out if the username sjobs is still available:

GET localhost:8080/isUserNameAvailable?userName=sjobs HTTP/1.1

The server would then reply with a short JSON document stating if the username is still available:

HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 39
connection: close
date: Thu, 06 Jan 2022 16:09:08 GMT

{"isAvailable":false, "userName":"sjobs"}

To perform this request in Swift, we can use URLSession. The traditional way to fetch data from the network using URLSession looks like this:

func checkUserNameAvailableOldSchool(userName: String, completion: @escaping (Result<Bool, NetworkError>) -> Void) {
  guard let url = URL(string: "http://127.0.0.1:8080/isUserNameAvailable?userName=\(userName)") else { 2
    completion(.failure(.invalidRequestError("URL invalid")))
    return
  }
  
  let task = URLSession.shared.dataTask(with: url) { data, response, error in
    if let error = error { 3
      completion(.failure(.transportError(error)))
      return
    }
    
    if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { 4
      completion(.failure(.serverError(statusCode: response.statusCode)))
      return
    }
    
    guard let data = data else { 5
      completion(.failure(.noData))
      return
    }
    
    do {
      let decoder = JSONDecoder()
      let userAvailableMessage = try decoder.decode(UserNameAvailableMessage.self, from: data)
      completion(.success(userAvailableMessage.isAvailable)) 1
    }
    catch {
      completion(.failure(.decodingError(error)))
    }
  }
  
  task.resume() 6
}

And while this code works fine and nothing is inherently wrong with it, it does have a number of issues:

  1. It’s not immediately clear what the happy path is - the only location that returns a successful result is pretty hidden (1), and developers who are new to using completion handlers might be confused by the fact that the happy path doesn’t even use a return statement to deliver the result of the network call to the caller.
  2. Error handling is scattered all over the place (2, 3, 4, 5).
  3. There are several exit points, and it’s easy to forget one of the return statements in the if let conditions.
  4. Overall, it is hard to read and maintain, even if you’re an experienced Swift developer.
  5. It’s easy to forget you have to call resume() to actually perform the request (6). I am pretty sure most of us have been frantically looking for bugs, only to find out we forgot to actually kick off the request using resume. And yes, I think resume is not a great name for an API that is inteded to send the request.

Running the code samples

You will find all the code samples in the accompanying GitHub repository, in the Networking folder. To be able to benefit the most, I've also provided a demo server (built with Vapor) in the server subfolder. To run it on your machine, do the following:

$ cd server

$ swift run

How to fetch data using Combine

When they introduced Combine, Apple added publishers for many of their own asynchronous APIs. This is great, as this makes it easier for us to use them in our own Combine pipelines.

Now, let’s take a look at how the code looks like after refactoring it to make use of Combine.

func checkUserNameAvailable(userName: String) -> AnyPublisher<Bool, Never> {
  guard let url = URL(string: "http://127.0.0.1:8080/isUserNameAvailable?userName=\(userName)") else {
    return Just(false).eraseToAnyPublisher()
  }
  
  return URLSession.shared.dataTaskPublisher(for: url) 1
    .map { data, response in 2
      do {
        let decoder = JSONDecoder()
        let userAvailableMessage = try decoder.decode(UserNameAvailableMessage.self, from: data)
        return userAvailableMessage.isAvailable 3
      }
      catch {
        return false 4
      }
    }
    .replaceError(with: false) 5
    .eraseToAnyPublisher()
}

This is a lot easier to read already, and (except for the guard statement that makes sure we’ve got a valid URL) there is just one exit point.

Let’s walk through the code step by step:

  1. We use dataTaskPublisher to perform the request. This publisher is a one-shot publisher and will emit an event once the requested data has arrived. It's worth keeping in mind that Combine publishers don't perform any work if there is no subscriber. This means that this publisher will not perform any call to the given URL unless there is at least one subscriber. I will later show you how to connect this pipeline to the UI and make sure it gets called every time the user enters their preferred username.
  2. Once the request returns, the publisher emits a value that contains both the data and the response . In this line, we use the map operator to transform this result. As you can see, we can reuse most of the data mapping code from the previous version of the code, except for a couple of small changes:
  3. Instead of calling the completion closure, we can return a Boolean value to indicate whether the username is still available or not. This value will be passed down the pipeline.
  4. In case the data mapping fails, we catch the error and just return false, which seems to be a good compromise.
  5. We do the same for any errors that might occur when accessing the network. This is a simplification that we might need to revisit in the future.

This looks a lot better and easier to read than the initial version, and we could stop here, and integrate this in out application.

But we can do better. Here are three changes that will make the code more linear and easier to reason about:

Destructuring tuples using key paths

We often find ourselves in a situation where we need to extract a specific attribute from a variable. In our example, we receive a tuple containing the data and the response of the URL request we sent. Here is the respective declaration in URLSession:

public struct DataTaskPublisher : Publisher {

  /// The kind of values published by this publisher.
  public typealias Output = (data: Data, response: URLResponse)
  ...
}

Combine provides an overloaded version of the map operator that allows us to destructure the tuple using a key path, and access just the attribute we care for:

return URLSession.shared.dataTaskPublisher(for: url)
  .map(\.data) 

Mapping Data more easily

Since mapping data is such a common task, Combine comes with dedicated operator to make this easier: decode(type:decoder:).

return URLSession.shared.dataTaskPublisher(for: url)
  .map(\.data)
  .decode(type: UserNameAvailableMessage.self, decoder: JSONDecoder())

This will return decode the data value from the upstream publisher and decode it into a UserNameAvailableMessage instance.

And finally, we can use the map operator again to destructure the UserNameAvailableMessage and access its isAvailable attribute:

return URLSession.shared.dataTaskPublisher(for: url)
  .map(\.data)
  .decode(type: UserNameAvailableMessage.self, decoder: JSONDecoder())
  .map(\.isAvailable)

Fetching data using Combine, simplified

With all these changes in place, we now have version of the pipeline that is easy to read, and has a linear flow:

func checkUserNameAvailable(userName: String) -> AnyPublisher<Bool, Never> {
  guard let url = URL(string: "http://127.0.0.1:8080/isUserNameAvailable?userName=\(userName)") else {
    return Just(false).eraseToAnyPublisher()
  }
  
  return URLSession.shared.dataTaskPublisher(for: url)
    .map(\.data)
    .decode(type: UserNameAvailableMessage.self, decoder: JSONDecoder())
    .map(\.isAvailable)
    .replaceError(with: false)
    .eraseToAnyPublisher()
}

How to connect to SwiftUI

Let’s finish off by looking at how to integrate this new Combine pipeline in our hypothetical sign up form.

Here is a condensed version a sign up form that contains just a username field, a Text label to display a message, and a sign up button. In a real application, we’d also have some UI elements to provide a password and a password confirmation.

struct SignUpScreen: View {
  @StateObject private var viewModel = SignUpScreenViewModel()
  
  var body: some View {
    Form {
      // Username
      Section {
        TextField("Username", text: $viewModel.username)
          .autocapitalization(.none)
          .disableAutocorrection(true)
      } footer: {
        Text(viewModel.usernameMessage)
          .foregroundColor(.red)
      }
      
      // Submit button
      Section {
        Button("Sign up") {
          print("Signing up as \(viewModel.username)")
        }
        .disabled(!viewModel.isValid)
      }
    }
  }
}

All UI elements are connected to a view model to separate concerns and keep the view clean and easy to read:

class SignUpScreenViewModel: ObservableObject {
  // MARK: Input
  @Published var username: String = ""
  
  // MARK: Output
  @Published var usernameMessage: String = ""
  @Published var isValid: Bool = false
  ...
}

Since @Published properties are Combine publishers, we can subscribe to them to receive updates whenever their value changes. This allows us to call the checkUserNameAvailable pipeline we created above.

Let’s create a reusable publisher that we can use to drive the parts of our UI that need to display information that depends on whether the username is available or not. One way to do this is to create a lazy computed property. This makes sure the pipeline will only be set up once it is needed, and there will be only one instance of the pipeline.

private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = {
  $username
    .flatMap { username in
      self.authenticationService.checkUserNameAvailable(userName: username)
    }
    .eraseToAnyPublisher()
}()

To call another pipeline and then use its result, we can make use of the flatMap operator. This will take all input events from an upstream publisher (i.e., the values emitted by the $username published property), and transform them into a new publisher (in our case, the publisher checkUserNameAvailable in ).

In the next and final step, we will connect the result of the isUsernameAvailablePublisher to the UI. If you take a look at the view model, you will notice we’ve got two properties in the output section of the view model: one for any message related to the username, and another one that holds the overall validation state of the form (remember, in a real sign up form, we might need to validate the password fields as well).

Combine publishers can be connected to more than one subscriber, so we can connect both isValid and usernameMessage to the isUsernameAvailablePublisher:

class SignUpScreenViewModel: ObservableObject {
  ...
  init() {
    isUsernameAvailablePublisher
      .assign(to: &$isValid)
    
    isUsernameAvailablePublisher
      .map { $0 ? "" : "Username not available. Try a different one."}
      .assign(to: &$usernameMessage)
  }
}

Using this approach allows us to reuse the isUsernameAvailablePublisher and use it to drive both the overall isValid state of the form (which will enable / disable the Submit button, and the error message label which informs the user whether their chosen username is still available or not.

How to handle Publishing changes from background threads is not allowed

When you run this code, you will notice a couple of issues:

  1. The API endpoint gets called several times for each character you type
  2. Xcode tells you that you shouldn't update the UI from a background thread

We are going to dive deeper into the reasons for these issues in the next episodes, but for now, let's address this error message:

[SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

The reason for this error message is that Combine will execute the network request on a background thread. When the request is fulfilled, we assign the result to one of the published properties on the view model. This, in turn, will prompt SwiftUI to update the UI - and this will happen on the foreground thread.

To prevent this from happening, we need to instruct Combine to switch to the foreground thread once it has received the result of the network request, using the receive(on:) operator:

private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = {
  $username
    .flatMap { username -> AnyPublisher<Bool, Never> in
      self.authenticationService.checkUserNameAvailableNaive(userName: username)
    }
    .receive(on: DispatchQueue.main)
    .eraseToAnyPublisher()
}()

We will look deeper into threading in one of the next episodes when we talk about Combine schedulers.

Closure

In this post, I showed you how to access the network using Combine, and how this enables you to write straight-line code that should be easier to read and maintain than the respective callback-driven counterpart.

We also looked at how to connect a Combine pipeline that makes network requests to SwiftUI by using a view model, and attaching the pipeline to an @Published property.

Now, you might be wondering why isUsernameAvailablePublisher uses Never as its error type - after all, network errors very much are something that we need to deal with.

We will look into error handling (and custom data mapping) in one of the next episodes. We will also look at ways to optimise our Combine-based networking layer, so stay tuned!

Thanks for reading 🔥

Source Code
Newsletter
Enjoyed reading this article? Subscribe to my newsletter to receive regular updates, curated links about Swift, SwiftUI, Combine, Firebase, and - of course - some fun stuff 🎈