async/await in SwiftUI

Convert a SwiftUI app to use the new Swift concurrency and find out what’s going on beneath the shiny surface. By Audrey Tam.

4.6 (16) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 3 of 5 of this article. Click here to view the first page.

Sequential Binding

Instead, you’ll use the other binding: sequential binding.

Replace the two lines with this:

let (data, response) = try? await URLSession.shared.data(from: url)

Unlike async let, calling data(from:) this way doesn’t create a child task. It runs sequentially as a job in the fetchJoke() task. While it’s waiting for the server response, this job suspends itself, releasing the task’s thread.

The task’s data(from:) job suspends.

The task's data(from:) job suspends.

The task’s data(from:) job suspends.

But there’s a problem:

Xcode refuses to understand try? here.

Xcode refuses to understand try? here.

Xcode refuses to understand try? here.

You try the lazy way, but Xcode won’t have it this time, not even if you use nil coalescing to specify a nil tuple:

let (data, response) = 
  try? await URLSession.shared.data(from: url) ?? (nil, nil)

Nope, you’ll have to do the right thing. First, delete the ? and ?? (nil, nil):

let (data, response) = try await URLSession.shared.data(from: url)

Error Handling Options

You have two options for handling errors thrown by data(from:). The first is to bite the bullet and handle it right away with do/catch:

do {
  let (data, response) = try await URLSession.shared.data(from: url)
} catch {
  print(error.localizedDescription)
}

The easier(?) option is to make fetchJoke() throw:

func fetchJoke() async throws {

These keywords must appear in this order — throws async doesn’t work:

async must precede throws.

async must precede throws.

async must precede throws.

Now fetchJoke() just passes the error up to whatever calls fetchJoke(). That’s the button in ContentView, where Xcode is already complaining about fetchJoke() being asynchronous:

fetchJoke() is async and throws: Do something!

fetchJoke() is async and throws: Do something!

fetchJoke() is async and throws: Do something!

Now what do you do? You can’t mark anything in ContentView as async.

Creating an Unstructured Task

Fortunately, you can create an asynchronous task in the button action. Replace Button { jokeService.fetchJoke() } label: { with this:

Button {
  async {
    try? await jokeService.fetchJoke()
  }
} label: {

You create an asynchronous task with async { }. Because it’s asynchronous, you have to await its completion. Because it throws, you have to try to catch any errors. Xcode lets you use try? here, or you can write a do/catch statement.

Note: The task creation syntax will change to Task { ... } in a future beta.

This is an unstructured task because it’s not part of a task tree. The async let task you created in fetchJokes() is a child task of the task that’s running fetchJokes(). A child task is bound to the scope of its parent task: The fetchJokes() task cannot finish until its child tasks have finished.

An unstructured task inherits the actor, priority and local values of its origin, but isn’t bound by its scope. Cancelling its originating task doesn’t signal the unstructured task, and the originating task can finish even if the unstructured task has not finished.

Creating an unstructured task in a non-asynchronous context feels just like DispatchQueue.global().async with a little less typing. But there’s a big difference: It runs on the MainActor thread with userInteractive priority, when the main thread won’t be blocked.

You can specify a lower priority with asyncDetached:

Specify a priority for a detached task.

Specify a priority for a detached task.

Specify a priority for a detached task.

But it will still run on the main thread. More about this later.

Decoding the Joke

Back to JokeService.swift, to finish writing fetchJoke(). If you thought making it throw was the easier option, see what you think after this section.

Because fetchJoke() throws, it passes any error thrown by data(from:) to the calling function. You might as well take advantage of this mechanism and throw other errors that can happen.

Errors thrown by a throwing function must conform to the Error protocol, so add this code above the JokeService extension:

enum DownloadError: Error {
  case statusNotOk
  case decoderError
}

You create an enumeration for possible errors fetchJoke() can throw.

Then add this code to fetchJoke():

guard 
  let httpResponse = response as? HTTPURLResponse,
  httpResponse.statusCode == 200   // 1
else {
  throw DownloadError.statusNotOk
}
guard let decodedResponse = try? JSONDecoder()
  .decode(Joke.self, from: data) // 2
else { throw DownloadError.decoderError }
joke = decodedResponse.value   // 3

Using guard lets you throw your specific errors to the calling function.

  1. You check the response status code and throw statusNotOk if it isn’t 200.
  2. You decode the response and throw decoderError if something goes wrong.
  3. You assign the decoded value to joke.
Note: You always have the option of catching an error, including those thrown by data(from:), instead of throwing it.

Now, where to set isFetching to false? This Published value controls the button’s ProgressView, so you want to set it even if fetchJoke() throws an error. Throwing an error exits fetchJokes(), so you still need to set isFetching in a defer statement, before any possible early exit.

Add this line right below isFetching = true:

defer { isFetching = false }

MainActor

If Xcode has trained you well, you might be feeling a little uneasy. Published values update SwiftUI views, so you can’t set Published values from a background thread. To set the Published values isFetching and joke, the dataTask(with:) completion handler dispatched to the main queue. But your new code doesn’t bother to do this. Will you get purple main thread errors when you run the app?

Try it. Build and run in a simulator. Nope, no main thread errors. Why not?

Because you used async { } to create the fetchJoke() task in the button action, it’s already running on the MainActor thread, with UI priority.

Actor is the Swift concurrency mechanism for making an object thread-safe. Like Class, it’s a named reference type. Its synchronization mechanism isolates its shared mutable state and guarantees no concurrent access to this state.

MainActor is a special Actor that represents the main thread. You can think of it as using only DispatchQueue.main. SwiftUI views all run on the MainActor thread, and so does the unstructured task you created.

To see this, place a breakpoint anywhere in fetchJoke(). Build and run, then tap the button.

fetchJoke() is running on the main thread.

fetchJoke() is running on the main thread.

fetchJoke() is running on the main thread.

Yes, fetchJoke() is running on the main thread.

What if you lower the priority? In ContentView.swift, in the button action, change async { to this:

asyncDetached(priority: .default) {
Note: The syntax of this will change to Task.detached in a future beta.

Build and run again. Tap the button:

fetchJoke() is still running on the main thread.

fetchJoke() is still running on the main thread.

fetchJoke() is still running on the main thread.

You lowered priority to default, but this doesn’t move the task to a background queue. The task still runs on the main thread!

Note: This seems to be a fluke. The Explore structured concurrency in Swift video says a detached task inherits nothing from its origin, so it shouldn’t inherit the MainActor thread. A future Xcode beta might enforce this.

Change the code back to async {.

To move the asynchronous work off the main thread, you need to create an actor that isn’t MainActor.