Modeling Screen States
Any data-fetching screen typically transitions through states such as idle, loading, loaded, and failure. In this post, I’ll demonstrate the evolution of a pattern, starting with a Combine-based implementation and refactoring it to a simpler solution using async/await
and a custom StateRelay
.
States
I’ll represent states as an enum, along with a LoadingProgress
object for more granular updates.
public enum LoadingState<Value: Hashable & Sendable>: Hashable, Sendable {
case idle
case loading(LoadingProgress?)
case failure(Error)
case loaded(Value)
}
public struct LoadingProgress: Hashable, Sendable {
public let isCanceled: Bool?
public let message: String?
public let percent: Int? // 0 to 100
}
Combine-Based
To standardize the handling of loadable content, we can define a protocol. A Combine-based version of this protocol might look like this:
/// An object that loads content (Legacy Combine version).
public protocol Loadable_Combine {
associatedtype Value: Sendable
/// Emits states about the loading process.
var state: PassthroughSubject<LoadingState<Value>, Never> { get }
/// Initiates the load process.
func load()
}
This approach worked, but it relied on the PassthroughSubject
, sink
, and AnyCancellable
boilerplate common to Combine. While powerful, modern Swift concurrency offers a cleaner path.
AsyncSequence
Apple’s focus has clearly shifted to async/await
and AsyncSequence
. They allow us to consume streams of values with a simple for await
loop, integrating naturally with Structured Concurrency and removing the need for manual subscription management. If needed, async algorithms provides operators similar to Combine.
Our modern Loadable protocol looks like this:
@MainActor
public protocol Loadable {
associatedtype Value: Hashable, Sendable
/// An asynchronous sequence that publishes the loading state.
var state: any AsyncSequence<LoadingState<Value>, Never> { get }
/// Flag indicating if the loading operation has been cancelled.
var isCanceled: Bool { get }
/// Cancels the ongoing loading operation.
func cancel()
/// Resets the loadable to its initial state.
func reset()
/// Initiates the loading of the value.
func load() async
}
Base implementation
Implementing this protocol is more verbose than using Combine, but that can be solved with a base implementation. Simply subclass and override the fetch()
method. The work of creating the stream and yielding .loading
, .loaded
, and .failure
states is handled automatically by BaseLoadable
.
@MainActor
class UserLoader: BaseLoadable<User> {
override func fetch() async throws -> User {
// Your async loading logic here
try await Task.sleep(nanoseconds: 1_000_000_000)
// Just return the value or throw an error
return User(name: "Jane Doe")
}
}
The LoadingView
With this new model, the LoadingView
also becomes simpler. It no longer needs a ViewModel
; it can directly observe the Loadable
object’s state using the .task
modifier.
struct LoadingView<L: Loadable, Content: View>: View {
private var loader: L
private var content: (L.Value) -> Content
@State private var loadingState: LoadingState<L.Value> = .idle
init(loader: L, @ViewBuilder content: @escaping (L.Value) -> Content) {
self.loader = loader
self.content = content
}
var body: some View {
Group {
switch loadingState {
case .loaded(let value):
content(value)
// ... render other states
default:
ProgressView("Loading...")
}
}
.task {
for await state in loader.state {
self.loadingState = state
}
}
}
}
State Persistence with StateRelay
A key problem with AsyncStream
is that it’s a “one-time” event pipe. If you navigate away from a view, its .task
is cancelled. When you navigate back, a new observer is created, but the AsyncStream
doesn’t “replay” its last value. This causes the UI to revert to its .idle
state, even if data was already loaded.
To solve this, the library includes StateRelay
: a custom state-holding broadcaster.
- It holds the current state.
- It replays the current state to new subscribers immediately.
- It supports multiple observers.
BaseLoadable
uses StateRelay internally, so any loader that inherits from it gets this robust state persistence for free. This ensures that your UI remains consistent even during complex navigation flows.
Composing loaders
The Loadable
protocol and BaseLoadable
class create a powerful foundation for composition. The library includes wrappers that add functionality to any Loadable
object.
Retry
Loaders can be composed. Wrapping a loader RetryableLoader
provides automatic retry capabilities with exponential backoff.
// Create a loader that might fail
let flakeyLoader = FlakeyLoader(successAfterAttempts: 3)
// Wrap it to add retry logic
let retryableLoader = RetryableLoader(
base: flakeyLoader,
maxAttempts: 5
)
// Use it in the view. It will automatically retry on failure.
LoadingView(loader: retryableLoader) { ... }
Debounce
Another example for composition is the debouncing of values in a search field. This wrapper delays load calls until the user stops typing.
// Create a loader that performs a search
let searchLoader = SearchLoader()
// Wrap it to add debouncing
let debouncedLoader = await DebouncingLoadable(
wrapping: searchLoader,
debounceInterval: 0.5 // 500ms
)
// In the view, call load() on every keystroke.
// The wrapper ensures the actual search is only triggered when needed.
TextField("Search...", text: $searchText)
.onChange(of: searchText) {
Task { await debouncedLoader.load() }
}
Limiting concurrency
This example uses a token bucket, see the source at ConcurrencyLimitingLoadable.
Button("Start Downloads") {
let baseLoader = ParallelDownloadLoader(itemCount: numberOfItems)
loader = ConcurrencyLimitingLoadable(
wrapping: baseLoader,
concurrencyLimit: concurrencyLimit
)
}
Conclusion
This is what we got
- Consistency with a reusable solution
- Type-safety with an enum-based state
- Customizable views
- Progress tracking
- Composable loaders
The source is available on GitHub. The final architecture provides a reusable, powerful, and easy-to-use pattern for managing screen states in any SwiftUI application.