Loading JSON data with async/await

by @ralfebert · updated September 27, 2021
SwiftUI, Xcode 13 & iOS 15
Advanced iOS Developers
English

This tutorial shows step by step the new Swift language feature async/await for concurrent programming. As an example, loading JSON data in the background is used. The JSON data is loaded with URLSession, decoded with JSONDecoder and displayed with SwiftUI.

This tutorial assumes prior knowledge of Swift & SwiftUI. If you haven't worked with SwiftUI before, I recommend working through the → introductory SwiftUI tutorial first.

  1. Use the latest version of Xcode 13 for this tutorial (this tutorial was last tested on September 27, 2021 with Xcode 13).

  2. Create a new app project Countries based on SwiftUI.

  3. Add a new Swift file with a datatype Country to the project. Declare some static sample data here:

    struct Country: Identifiable {
        var id: String
        var name: String
    
        static let allCountries = [
            Country(id: "be", name: "Belgium"),
            Country(id: "bg", name: "Bulgaria"),
            Country(id: "el", name: "Greece"),
            Country(id: "lt", name: "Lithuania"),
            Country(id: "pt", name: "Portugal"),
        ]
    }
    
  4. Implement a list of the countries in ContentView with a List-View:

    Beispielprojekt Countries für UITableViewController
    struct ContentView: View {
        var body: some View {
            List(Country.allCountries) { country in
                Text(country.name)
            }
        }
    }
    
  5. Rename the ContentView via Refactor » Rename to CountriesView.

  6. Open the sample JSON data in the browser and familiarize yourself with the format of the data:

    Anzeige der JSON-Beispieldaten im Browser
  7. Create a new class CountriesModel that is responsible for loading and holding the data. Let it inherit from ↗ ObservableObject and declare a ↗ @Published property named countries. This makes the object observable - when the list of countries changes later, the view can react to it.

    class CountriesModel: ObservableObject {
        @Published var countries = Country.allCountries
    }
    
  8. Use this object for the country list in the View. Declare a new @StateObject property, so that SwiftUI manages the instance of the object and automatically updates the view when changes are made:

    struct CountriesView: View {
        @StateObject var countriesModel = CountriesModel()
    
        var body: some View {
            List( countriesModel.countries) { country in
                Text(country.name)
            }
        }
    }
    
  9. Create a method reload in the CountriesModel. Declare it as async - this is a new feature of iOS 15 for async operations:

    class CountriesModel: ObservableObject {
        @Published var countries = Country.allCountries
    
        func reload() async {
        }
    }
    
  10. Use the new asynchronous method data(from:) of URLSession to start a load operation. Use the keyword await to pause the execution of the method at that point and resume only when the data has been loaded:

    func reload() async {
        let url = URL(string: "https://www.ralfebert.de/examples/v3/countries.json")!
        let urlSession = URLSession.shared
        let (data, response) = try! await urlSession.data(from: url)
    }
    

    Note: The return type of this method is a ↗ tuple that contains two values: the loaded data and the HTTP response. With the syntax above, you can directly assign the two parts.

  11. Add provisional error handling so that the app doesn't crash due to the try! crashes in case of an error:

    do {
        let (data, response) = try await urlSession.data(from: url)
    }
    catch {
        // Error handling in case the data couldn't be loaded
        // For now, only display the error on the console
        debugPrint("Error loading \(url): \(String(describing: error))")
    }
    
  12. In Countries.swift, remove the allCountries property with the sample data and declare the type as Codable:

    struct Country : Identifiable, Codable {
        var id: String
        var name: String
    }
    
  13. Modify the countries property in the CountriesModel so that it is initially initialized with an empty list:

    class CountriesModel: ObservableObject {
        @Published var countries : [Country] = []
    
        // ...
    }
    
  14. Add decoding of the loaded JSON data after the await call. Here, the new async/await language feature no longer requires cumbersome completion handlers, but instead allows you to continue working directly with the loaded data. Use a JSONDecoder here to decode the loaded data:

    func reload() async {
        let url = URL(string: "https://www.ralfebert.de/examples/v2/countries.json")!
        let urlSession = URLSession.shared
    
        do {
            let (data, response) = try await urlSession.data(from: url)
            self.countries = try JSONDecoder().decode([Country].self, from: data)
        }
        catch {
            // Error handling in case the data couldn't be loaded
            // For now, only display the error on the console
            debugPrint("Error loading \(url): \(String(describing: error))")
        }
    }
    
  15. In the CountriesView, try to add an onAppear-modifier to trigger loading the data:

    struct CountriesView: View {
        @StateObject var countriesModel = CountriesModel()
    
        var body: some View {
            List(countriesModel.countries) { country in
                Text(country.name)
            }
            .onAppear {
        self.countriesModel.reload()
    }
        }
    }
    

    This will cause an error: methods declared as async must not be called just like that. Only as part of a Task pausing and resuming works according to the async/await principle:

  16. Instead, use the .task to trigger the loading. Also add a .refreshable to support reloading the data by dragging down the view:

    struct CountriesView: View {
        @StateObject var countriesModel = CountriesModel()
    
        var body: some View {
            List(countriesModel.countries) { country in
                Text(country.name)
            }
            .task {
        await self.countriesModel.reload()
    }
    .refreshable {
        await self.countriesModel.reload()
    }
        }
    }
    
  17. If you run the app like this, you'll see a warning: 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. This is because the continuation of the execution of the reload method after the interruption from await is run on the background thread that loaded the data.

    This problem can be solved by declaring the entire class as @MainActor to ensure that all methods of this class are executed on the main thread:

    @MainActor
    class CountriesModel: ObservableObject {
        // ...
    }
    
  18. Start the app with Product » Run ⌘R and check that the countries are loaded and displayed. It should also be possible to reload the data by dragging it down:

    Ergebnis Länderanzeige via JSON

    Show solution

    struct Country: Identifiable, Codable {
        var id: String
        var name: String
    }
    
    @MainActor
    class CountriesModel: ObservableObject {
        @Published var countries : [Country] = []
    
        func reload() async {
            let url = URL(string: "https://www.ralfebert.de/examples/v2/countries.json")!
            let urlSession = URLSession.shared
    
            do {
                let (data, _) = try await urlSession.data(from: url)
                self.countries = try JSONDecoder().decode([Country].self, from: data)
            }
            catch {
                // Error handling in case the data couldn't be loaded
                // For now, only display the error on the console
                debugPrint("Error loading \(url): \(String(describing: error))")
            }
        }
    }
    
    struct CountriesView: View {
        @StateObject var countriesModel = CountriesModel()
    
        var body: some View {
            List(countriesModel.countries) { country in
                Text(country.name)
            }
            .task {
                await self.countriesModel.reload()
            }
            .refreshable {
                await self.countriesModel.reload()
            }
        }
    }
    

More information