Maintaining consistency when modeling screen states calls for a reusable solution. In this post, I’ll demonstrate a Combine-based implementation and then refactor it to use async/await with Async Algorithms.

States

Any data-fetching screen typically transitions through states such as idle, loading, loaded, and failure. Naturally, these states can be represented with an enum, along with a Progress object for more granular updates.

enum LoadingState<Value: Sendable>: Sendable {
    case idle
    case loading(Progress?)
    case failure(Error)
    case loaded(Value)
}
struct Progress: Sendable, Equatable {
    let isCancelled: Bool?
    let message: String?
    let percent: Int?
}

Loadable

To standardize the handling of loadable content, define a protocol that every loading operation must conform to. For testing we’ll create mock loaders that emit predetermined sequences of states.

/// An object that loads content.
public protocol Loadable {
    
    /// Value to be loaded
    associatedtype Value: Sendable
    
    /// Emits states about the loading process.
    var state: PassthroughSubject<LoadingState<Value>, Never> { get }
        
    /// Initiates the load process
    func load()
}

In this implementation

  • var state could be a closure type, but Combine has sofisticated functionality, operators, back pressure management, and multiple listeners. I’m in for not having to implement debounce, etc.
  • Never requires that errors are catched and sent as a .failure(error) states instead.
  • A conforming object will be held by the view’s ViewModel. Since the ViewModel is more about coordination, it makes for a nice separation of roles.

Here’s an example usage:

class UserLoader: Loadable {
    var isCancelled = false
    let state = PassthroughSubject<LoadingState<User>, Never>()

    func load() {
        state.send(.loading(Progress()))
        Task {
            do {
                let userData = try await fetchUser()
                state.send(.loaded(userData))
            } catch {
                state.send(.failure(error))
            }
        }
    }

    func fetchUser() async throws -> User {
        try await Task.sleep(nanoseconds: 1_000_000_000)
        return User()
    }
}

Additional complexity could include:

  • Sending a Progress instance with increasing completion percentages
  • Checking isCancelled before emitting new state
  • Composing loadables to modify the wrapped loadable’s behavior (e.g., adding debouncing or retries)

But let’s move to the view instead.

Loading View

I typically design components by imagining how they’ll be used in code. For example:

#Preview {
    LoadingView(UserLoader()) { value in
        Text("\(value)")
    }
}

That requires an init where a closure builds the view content, and a loader providing states.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct LoadingView<L: Loadable, Content: View>: View {
    let viewModel: LoadingViewModel<L>
    var content: (L.Value) -> Content

    init(loader: L, @ViewBuilder content: @escaping (L.Value) -> Content) {
        self.viewModel = LoadingViewModel(loader: loader)
        self.content = content
    }

    var body: some View {
        switch viewModel.loadingState {
        case .loaded(let value):
            content(value)
        // ... render other states
        default:
            EmptyView()
        }
    }
}

Note: the viewModel doesn’t need @State because I plan to use the Observation framework.

Custom views

To customize the views for each state we need to store the views, and create modified copies of the struct. It looks like view modifiers, but it’s just regular methods. What counts is the final view returned.

private var _errorView: (Error) -> any View = { error in
    Text("Error: \(error.localizedDescription)")
        .accessibilityLabel("An error occurred")
        .accessibilityValue(error.localizedDescription)
}
    
func errorView(@ViewBuilder _ view: @escaping (Error) -> any View) -> Self {
    var copy: Self = self
    copy._errorView = view
    return copy
}

...
// render error
case .failure(let error):
    AnyView(_errorView(error))

It’s a bit involved, but now we can do this:

LoadingView(UserLoader()) { value in
        Text("\(value)")
    }
    .errorView { error  in
        Text("My own error view: \(error.localizedDescription)")
    }

ViewModel

Now let’s look at how the ViewModel ties these pieces together. It primarily listens to state updates through a sink. I still like it to be in a separate file so I don’t have to read it if I don’t need to.

@Observable
final class LoadingViewModel<L: Loadable & Sendable>: Sendable {
    private var loader: L
    private var cancellables = Set<AnyCancellable>()
    var loadingState: LoadingState<L.Value> = .loading(nil)

    init(loader: L) {
        self.loader = loader
        loader.state
            .receive(on: DispatchQueue.main)
            .sink { [weak self] state in
                self?.loadingState = state
            }
            .store(in: &cancellables)
    }

    func load() async {
        loader.load()
    }
}

The pattern receive/sink/store is a common occurrence with Combine. But modern Swift has a better way to consume streams of values:

for await state in loader.state {
    loadingState = state
}

This illustrates why Apple is focusing efforts in AsyncSequence. We get isolation from the containing object, no need to manage the subscription, and it’s naturally integrated with Swift and Structured Concurrency.

AsyncSequence

Apple hasn’t updated Combine for years. Meanwhile, Async Algorithms provides a modern approach to reactive-style operators in Swift’s concurrency system. So unless you need multiple listeners, which is rarely the case, there is little reason to keep using Combine.

The protocol replacement is straightforward:

@MainActor
protocol Loadable {
    associatedtype Value: Sendable
    var state: any AsyncSequence<LoadingState<Value>, Never> { get }
    var isCancelled: Bool { get set }
    func load() async
}

Implementation notes:

  • I added @MainActor because an easy way to adopt Structure Concurrency is to explicitly do that for code intended to run on main. Otherwise we have to proof to the compiler that it’s multi thread-safe.
  • I wrote AsyncSequence and not AsyncStream because operators transform the type of the sequence. eg. AsyncStream + debounce = AsyncDebounceSequence.

Now for the slightly convoluted part. We need to

  1. Create an internal AsyncStream but store it as AsyncSequence
  2. Also store its continuation, because this is what we’ll use to send updates

This is the sauce:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@MainActor
final class UserLoader: Loadable, Sendable {
    // MARK: - Loadable
    public var isCancelled = false
    public var state: any AsyncSequence<LoadingState<User>, Never>

    // `state` implementation
    private let internalStream: AsyncStream<LoadingState<User>>

    // continuation of the internalStream, where we’ll be yielding states
    private var continuation: AsyncStream<LoadingState<User>>.Continuation?

    public init() {
        var localContinuation: AsyncStream<LoadingState<User>>.Continuation!
        let stream = AsyncStream<LoadingState<User>> { continuation in
            localContinuation = continuation
        }
        self.internalStream = stream
        self.continuation = localContinuation
        self.state = stream
            // Add here any transformation from AsyncAlgorithms, e.g.
            // .debounce(for: .seconds(0.5))

        self.continuation?.onTermination = { @Sendable _ /* termination */ in
            Task { @MainActor [weak self] in
                self?.isCancelled = true
            }
        }
    }
...

The init is a bit long but I can’t use self before all is initialized. Line 21 is important: that’s where we add the operators from Async Algorithms. If not for that single line we would have to compose that behavior.

The rest is easy:

...
/// Initiates loading, publishing relevant states along the way.
public func load() async {
    guard let continuation = continuation, !isCancelled else {
        return
    }

    // Loading at 0% progress.
    // Pass progress info or skip it with .loading(nil)
    continuation.yield(.loading(Progress()))

    do {
        let user = try await fetchUser()
        guard !isCancelled else {
            return // client cancelled, result no longer needed
        }
        continuation.yield(.loaded(user))
    } catch {
        guard !isCancelled else {
            return // client cancelled, skip sending an error
        }
        continuation.yield(.failure(error))
    }
}

Conclusion

This is what we got

  • Consistency with a reusable solution
  • Type-safety with an enum-based state
  • Customizable views
  • Progress tracking
  • Accessibility support for error states
  • Composable loadables

Composing loaders

You can compose by calling multiple loaders:

@MainActor
final class ProfileWithPostsLoader: Loadable {
    typealias Value = (user: User, posts: [Post])

    private let userLoader: UserLoader
    private let postsLoader: PostsLoader
    var state: any AsyncSequence<LoadingState<Value>, Never>

    func load() async {
        continuation?.yield(.loading(Progress()))
        do {
            async let user = userLoader.fetchUser()
            async let posts = postsLoader.fetchPosts()
            let result = try await (user, posts)
            continuation?.yield(.loaded(result))
        } catch {
            continuation?.yield(.failure(error))
        }
    }
}

Or by wrapping other loaders:

This example source is in GitHub. Features that could be added next:

  • retry for failed operations –gave it a try here.
  • timeout for long-running operations
  • a composed loader to cache results

This approach to state management could be adapted to UIKit or other UI frameworks. It could use more work, but I’m looking forward to add it to a Tuist template and reuse it in future projects.