June 23rd, 2025

Loading. Error. All Good.

Years of building apps have taught me to create reusable components to prototype and build faster. One thing that keeps coming back: handling state when fetching asynchronous data.

3 screens with loading, data, and error states Three screens you see all the time.

After some experimentation, I landed on this data structure:

@MainActor
@Observable
final class DataState<T> {
    enum State {
        case loading
        case loaded(T)
        case error(Error)
    }
 
    var state: State
 
    init(_ state: State) {
      self.state = state
    }
}

This DataState can be used like this:

@MainActor
@Observable
final class ViewModel {
    var user: DataState<User> = DataState(.loading)
 
    func loadUser() async {
        user.state = .loading
        do {
            try await Task.sleep(for: .seconds(1))
            user.state = .loaded(User())
        } catch {
            user.state = .error(error)
        }
    }
}

And displayed like this:

struct ContentView: View {
    @State private var viewModel = ViewModel()
 
    var body: some View {
        switch viewModel.user {
            case .loading:
                ProgressView()
                    .task {
                        await viewModel.loadUser()
                    }
            case let .loaded(user):
                Text(user.name)
            case let .error(error):
                Text(error.localizedDescription)
        }
    }
}

Okay, we have something reusable, but we've clearly lost some readability and simplicity. That's because we introduced a new data structure without moving the business logic into it.

To fix this, let's first create a new view to encapsulate the switch logic.

struct DataStateView<T, Content: View>: View {
    private let dataState: DataState<T>
    private let makeContent: (T) -> Content
 
    init(
         _ dataState: DataState<T>,
        @ViewBuilder content: (_ data: T) -> Content
    ) {
        self.dataState = dataState
        self.makeContent = content
    }
 
    var body: some View {
        switch dataState.state {
            case .loading:
                ProgressView()
            case let .loaded(data):
                makeContent(data)
            case let .error(error):
                Text(error.localizedDescription)
        }
    }
}

Now we can simplify our main view by using DataStateView instead of the switch:

struct ContentView: View {
    @State private var viewModel = ViewModel()
 
    var body: some View {
        DataStateView(viewModel.user) { user in
            Text(user.name)
        }
        .task {
            await viewModel.loadUser()
        }
    }
}

Much better! Now, let's make the initialization of a DataState more convenient.

extension DataState {
    static var loading: DataState {
        .init(state: .loading)
    }
 
    static func loaded(_ data: T) -> DataState {
        .init(state: .loaded(data))
    }
 
    static func error(_ error: Error) -> DataState {
        .init(state: .error(error))
    }
}

Now we can initialize our DataState like this:

// Before
var user: DataState<User> = DataState(.loading)
// After
var user: DataState<User> = .loading

We can even go further by creating a property wrapper.

@MainActor
@Observable
@propertyWrapper
final class AsyncData<T> {
    var wrappedValue: T? {
        get {
            guard case let .loaded(data) = dataState.state else {
                return nil
            }
            return data
        }
        set {
            dataState.state = .loaded(newValue)
        }
    }
 
    var projectedValue: DataState<T?> {
        dataState
    }
 
    private let dataState: DataState<T?>
 
    init(_ wrappedValue: T? = nil) {
        if let data = wrappedValue {
            self.dataState = .loaded(data)
        } else {
            self.dataState = .loading
        }
    }
}

Which lets us rewrite our view model like this:

@MainActor
@Observable
final class ViewModel {
    @AsyncData var user: User?
 
    func loadUser() async {
        $user.state = .loading
        do {
            try await Task.sleep(for: .seconds(1))
            user = User()
        } catch {
            $user.state = .error(error)
        }
    }
}

Nice, we've already shortened things a lot! But there's still a repeating pattern:

$user.state = .loading
do {
    try await Task.sleep(for: .seconds(1))
    user = User()
} catch {
    $user.state = .error(error)
}

When fetching async data, we always go through the same sequence:

  1. Set to loading.
  2. Run async code to fetch data.
  3. If there's no error, set the data. Otherwise, set to error.

Let's move this sequence into our DataState!

extension DataState {
    func load(_ loadData: @MainActor () async throws -> T) async {
        state = .loading
        do {
            let data: T = try await loadData()
            state = .loaded(data)
        } catch {
            state = .error(error)
        }
    }
}

Which simplifies our code even more! Here's the final result:

@MainActor
@Observable
final class ViewModel {
    @AsyncData var user: User?
 
    func loadUser() async {
        await $user.load {
            try await Task.sleep(for: .seconds(1))
            return User()
        }
    }
}
 
struct ContentView: View {
    @State private var viewModel = ViewModel()
 
    var body: some View {
        DataStateView(viewModel.$user) { user in
            if let user {
                Text(user.name)
            } else {
                Text("User not found")
            }
        }
        .task {
            await viewModel.loadUser()
        }
    }
}

Much simpler, more readable, and reusable! What do you think?

Going further

There's always room to improve. It's definitely possible (and even recommended) to make the error and loading states of DataStateView customizable. You could imagine a syntax like this:

DataStateView($user) { user in
    Text(user.name)
} loading: {
    ProgressView()
} error: { error in
    Text(error.localizedDescription)
}

This syntax is super handy, especially with a composition system. Imagine being able to add features to your view on the fly like this:

@MainActor
@Observable
final class ViewModel {
    @AsyncData var users: [User]?
    @SearchData(\ViewModel.users) var search
}

Maybe that'll be the topic of a future article if this solution turns out to be feasible and you're curious! How do you handle async loading logic in your apps? Share your ideas or feedback with me on Mastodon or Bluesky!

follow me here!