Working With The task Modifier In SwiftUI

It's often necessary to perform asynchronous tasks right when a view appears in SwiftUI. It's common, for instance, to initiate remote data fetching, or load and process data from the local database when a view has loaded and shown on-screen. One way to do that is to define a Task in the action closure of the onAppear view modifier, and add any asynchronous code there. Although it works, there's a better approach, and that is to use task; a dedicated modifier to run asynchronous code when a SwiftUI view appears.

Preparing the ground for .task

To demonstrate task, we'll use a view that loads fake profiles on appear. To make things as realistic as possible, we'll begin with the following type that represents a profile programmatically:

struct Profile: Identifiable, Hashable {
    let id: Int
    let name: String
    let country: String
    let email: String
    
    // Randomly generated names and email addresses.
    static let all: [Profile] = [
        Profile(id: 1, name: "Sofia Kim",
                country: "South Korea", email: "sofia.kim@example.com"),
        Profile(id: 2, name: "Liam O'Reilly",
                country: "Ireland", email: "liam.oreilly@example.com"),
        Profile(id: 3, name: "Nina Patel",
                country: "India", email: "nina.patel@example.com"),
        Profile(id: 4, name: "Jasper Wang",
                country: "Singapore", email: "jasper.wang@example.com"),
        Profile(id: -1, name: "Simulated Failure",
                country: "Unknown", email: "fail@example.com")
    ]
}

Besides the few profile properties, there's also the all static property that returns an array with a few Profile objects to use in the following examples. The last one distinguishes intentionally, as we'll use it later to do some error handling.

Instead of using an actual asynchronous operation, we'll fake one. The next fetchProfile(withID:) static method fetches and returns a matching Profile object from these specified previously. It's defined in another custom type called FakeAsyncService just for the sake of the simulated asynchronous process:

class FakeAsyncService {
    static func fetchProfile(withID id: Int) async throws -> Profile {
        // Simulate 1 second delay.
        try await Task.sleep(nanoseconds: 1_000_000_000)
        
        if let profile = Profile.all.first(where: { $0.id == id }) {
            if id == -1 {
                // Throw a fake error when the Profile with id
                // equal to -1 is requested.
                throw URLError(.badServerResponse)
            }
            
            return profile
        } else {
            // Throw another fake error if a Profile with
            // a non-existing id is requested.
            throw URLError(.fileDoesNotExist)
        }
    }
}

Notice that fetchProfile(withID:) is an asynchronous throwing method. It throws two fake errors just for the demonstration if the asked profile does not exist, or the profile with id value equal to -1 is intentionally requested.

In the view implementation things are simple. If a profile has been loaded then we'll show its details, otherwise a progress indication. In case of an error we only display it here, but that's just to keep things simple. Do not show actual error codes and messages to users in real apps unless it's really necessary, and handle them properly in a user-friendly way:

struct ProfileDetailView: View {
    @State private var profile: Profile?
    @State private var error: String?
    
    var body: some View {
        VStack(spacing: 20) {
            if let profile {
                // Show profile details.
                Text(profile.name)
                    .font(.title)
                
                Text(profile.country)
                
                Text(profile.email).foregroundColor(.blue)
            } else if let error {
                // In real apps handle the error properly!
                Text("Error: \(error)").foregroundColor(.red)
            } else {
                // Show a progress while fetching async data.
                ProgressView("Fetching profile...")
            }
        }
        .padding()
    }
}

Using .task

With the above setting the ground, let's use the task modifier to load a profile asynchronously. In its simplest form, all we have to do is to to apply it, and call the asynchronous operations in it:

VStack(spacing: 20) {
    // ... view content ...
}
.task {
    profile = try? await FakeAsyncService.fetchProfile(withID: 1)
}

See that it's not necessary to initialize a Task block, as task runs the code inside its closure asynchronously. Note that fetchProfile(withID:) is an error-throwing asynchronous method, so we call it with try and await. This particular example requests for the first profile, so this will return the actual data after one second.

Fetching asynchronous data dynamically

The previous example of task is good enough if the asynchronous data fetching does not depend on any parameters. However, this is not always the case. In our scenario, different profiles can be fetched based on the profile id we'll pass to the fetchProfile(withID:) method. The sample view we have here could be part of an app showing the details of a profile that's selected among others.

In order to get closer to that actual functionality, let's add one more view. It will present a list with profiles in a navigation stack, and it will navigate to the profile details when selecting one:

struct ProfileListView: View {
    var body: some View {
        NavigationStack {
            List(Profile.all) { profile in
                NavigationLink(profile.name) {
                    ProfileDetailView(profileID: profile.id)
                }
            }
            .navigationTitle("Profiles")
        }
    }
}

Let's update the ProfileDetailView so it can get the profile id as argument:

struct ProfileDetailView: View {
    let profileID: Int
    @State private var profile: Profile?
    @State private var error: String?
    
    var body: some View {
        VStack(spacing: 20) {
            // ... actual content ...
        }
        .padding()
        .navigationTitle("Profile Details")
    }
}

We'll apply task again, but this time we'll use a different initializer and we'll feed it with an additional argument; the profileID to fetch:

VStack(spacing: 20) {
    // ... view content ...
}
// ... other view modifiers ...
.task(id: profileID) {
    profile = try? await FakeAsyncService.fetchProfile(withID: profileID)
}

The id parameter can be any value matching to our implementation, as long as it conforms to the Equatable protocol. For clarity, id does not refer to any identifiers related to our models or data by default. It's just a value that SwiftUI observes for changes, and when these happen, the task is executed again. Passing a meaningful value as argument, like the profileID in the previous example, helps us fetch dynamic data asynchronously.

The code inside the task closure does not have to be a single line. Instead, we can apply any logic necessary to the app. For instance, we could do a better error handling in the throwing fetchProfile(withID:) method, and handle the error case:

.task(id: profileID) {
    do {
        profile = try await FakeAsyncService.fetchProfile(
            withID: profileID
        )
        
        // Ensure error property is nil.
        error = nil
    } catch {
        self.error = error.localizedDescription
        
        // Ensure profile is nil as there is an error.
        profile = nil
    }
}

Specifying the task priority

By default, an asynchronous task initiated in task modifier has the userInitiated priority. We can, however, set a different priority when initializing the modifier like so:

.task(priority: .background) {
    profile = try? await FakeAsyncService.fetchProfile(withID: 1)
}

It's also possible to provide arguments for both the id and the priority parameters:

.task(id: profileID, priority: .background) {
    profile = try? await FakeAsyncService.fetchProfile(withID: profileID)
}

Tasks are canceled automatically

Any asynchronous task initiated with task is canceled automatically if the view is dismissed before the asynchronous operation is finished. It's easy to find that out, as a cancellation error is thrown when a task is canceled before it completes. See the following example:

.task(id: profileID) {
    do {
        print("Will fetch profile with id: \(profileID)")
        
        // Delay profile fetching for 3 seconds so there's
        // enough time to navigate to the previous view.
        // Doing so simulates a long-running task.
        try await Task.sleep(nanoseconds: 3_000_000_000)
        
        // This code will be executed after 3 seconds and if
        // only we don't navigate to the previous view.
        profile = try await FakeAsyncService.fetchProfile(withID: profileID)
        error = nil
    } catch is CancellationError {
        // This message will be printed if we go to
        // the previous view while task is running.
        print("Task was cancelled!")
    } catch {
        profile = nil
        self.error = error.localizedDescription
    }
}

We get started in this task by delaying the execution for a period of time that's sufficient to let us go back to the previous view and dismiss the current one. If we do that in the given time frame, the fetchProfile(withID:) method won't be called. The catch block that handles the CancellationError will be executed instead, printing the given message to the console:

If, on the other hand, we don't make the view disappear, the code in the do block will be executed normally and the requested profile will be returned. If you're wondering, the following is taken from the Apple docs:

Cancellation Error
An error that indicates a task was canceled.

https://developer.apple.com/documentation/swift/cancellationerror

Conclusion

The task modifier is the right tool for running asynchronous code when a SwiftUI view appears. We've seen in this post all of its forms through the various initializers and the code examples that we've gone through. If you've been using onAppear and Task blocks, consider switching to task, as it makes it possible to cancel the task in case the view is dismissed while still executing asynchronous code. I hope you've found this tutorial useful. Thanks for reading!

Resources

Download the sample project