AsyncState is a tiny Swift package for modeling the lifecycle of asynchronously loaded data in a way that is predictable, testable, and UI-friendly.
It gives you a single enum that represents the full journey of a request: not started, first load, successful value, refresh in progress with stale data still visible, and failure.
Asynchronous UI code often ends up spreading state across multiple booleans and optional values:
isLoadingerroritemsisRefreshing
That approach makes it easy to represent invalid combinations such as "loading and failed and has no value and is refreshing". AsyncState replaces those loosely related values with one coherent model.
- It keeps screen state explicit and easy to reason about.
- It models refreshes without throwing away the last successful value.
- It works well in SwiftUI, UIKit, AppKit, and non-UI view models.
- It is easy to compare in tests because the enum is
Equatablewhen its generic parameters areEquatable. - It centralizes common transitions such as starting a refresh or restoring a non-loading state.
Add AsyncState to your Package.swift dependencies:
dependencies: [
.package(url: "https://github.com/your-name/AsyncState.git", from: "0.1.0")
]Then add the product to your target:
.target(
name: "FeatureModule",
dependencies: [
.product(name: "AsyncState", package: "AsyncState")
]
)AsyncState represents these cases:
.notLoaded: No request has started and no value is available..loading: A request is running and there is still no value..refreshing(value:): A request is running while a previous value remains available..success(value:): The latest request finished successfully..error(error:): The latest request failed.
The enum also exposes a few convenience properties:
value: Returns the value for.successand.refreshing.error: Returns the error for.error.isLoading: Returnstruefor.loadingand.refreshing.isError: Returnstruefor.error.
AsyncState includes focused mutation helpers for common flows.
startLoading():
.notLoadedbecomes.loading.errorbecomes.loading.success(value:)becomes.refreshing(value:).refreshing(value:)stays.refreshing(value:)
stopLoading():
.loadingbecomes.notLoaded.refreshing(value:)becomes.success(value:)- other states stay unchanged
setValue(_:):
.refreshing(value:)updates the value but keeps.refreshing- every other state becomes
.success(value:)
import AsyncState
import Foundation
@MainActor
final class ProfileViewModel: ObservableObject {
enum LoadError: Error, Equatable {
case offline
}
struct Profile: Equatable {
let name: String
let city: String
}
@Published private(set) var state: AsyncState<Profile, LoadError> = .notLoaded
func loadProfile() async {
state.startLoading()
do {
let profile = try await fetchProfile()
state.setValue(profile)
state.stopLoading()
} catch {
state = .error(error: .offline)
}
}
private func fetchProfile() async throws -> Profile {
try await Task.sleep(for: .milliseconds(300))
return Profile(name: "Lee Wong", city: "Singapore")
}
}The refreshing state is especially helpful when you want to keep rendering the last successful value while a new request is in flight.
import AsyncState
import Foundation
@MainActor
final class ArticleListViewModel: ObservableObject {
enum LoadError: Error, Equatable {
case requestFailed
}
struct Article: Equatable {
let id: UUID
let title: String
}
@Published private(set) var state: AsyncState<[Article], LoadError> = .notLoaded
func refresh() async {
state.startLoading()
do {
let articles = try await fetchLatestArticles()
state.setValue(articles)
state.stopLoading()
} catch {
if let currentArticles = state.value {
state = .success(value: currentArticles)
} else {
state = .error(error: .requestFailed)
}
}
}
private func fetchLatestArticles() async throws -> [Article] {
try await Task.sleep(for: .milliseconds(500))
return [
Article(id: UUID(), title: "Shipping Swift Features with Confidence"),
Article(id: UUID(), title: "Designing Better Loading States")
]
}
}import AsyncState
import SwiftUI
struct FeedView: View {
let state: AsyncState<[String], FeedError>
var body: some View {
switch state {
case .notLoaded:
ContentUnavailableView("Nothing loaded yet", systemImage: "tray")
case .loading:
ProgressView("Loading feed...")
case .refreshing(let items):
List(items, id: \.self) { item in
Text(item)
}
.overlay(alignment: .top) {
ProgressView()
.padding(.top, 8)
}
case .success(let items):
List(items, id: \.self) { item in
Text(item)
}
case .error(let error):
VStack(spacing: 12) {
Text("Could not load feed.")
Text(error.localizedDescription)
.foregroundStyle(.secondary)
}
}
}
}public enum AsyncState<Value: Equatable, Failure: Equatable>: Equatable {
case notLoaded
case loading
case refreshing(value: Value)
case success(value: Value)
case error(error: Failure)
public var value: Value? { get }
public var error: Failure? { get }
public var isLoading: Bool { get }
public var isError: Bool { get }
public mutating func startLoading()
public mutating func stopLoading()
public mutating func setValue(_ newValue: Value)
}ValueandFailureare constrained toEquatableso the entire state is easy to compare in tests and reducers.- Refreshing is a dedicated state instead of a boolean flag. That keeps stale-value rendering explicit.
setValue(_:)preserves.refreshingwhen needed so UI can continue showing a refresh indicator while intermediate updates arrive.- Transition helpers are intentionally small and opinionated. They cover the common cases without introducing a full state machine framework.
The package includes XCTest coverage for:
- every enum case
- computed properties
- state transition helpers
- equality behavior
Run the test suite with:
swift testThis package is released under the MIT License. See LICENSE.