A Swift package that provides elegant state management for asynchronous operations in iOS, macOS, watchOS, and visionOS applications.
AsyncLoad provides components for handling asynchronous operations:
AsyncLoad<T>: For loading data operationsAsyncLoad(without type): For operations that track loading state without data (usesNoContent)CachedAsyncLoad<T>: For loading operations that preserve cached data during refreshesAsyncLoadView: A SwiftUI view component for displaying async statesCachedAsyncLoadView: A SwiftUI view component for cached async states
- iOS 16.0+
- macOS 13.0+
- watchOS 9.0+
- visionOS 1.0+
- Swift 6.1+
Add AsyncLoad to your project by adding the following to your Package.swift:
dependencies: [
.package(url: "https://github.com/diamirio/AsyncLoad", from: "2.0.0")
]Or add it through Xcode:
- File → Add Package Dependencies
- Enter the repository URL
- Select the version and add to your target
An enum that represents the state of an asynchronous data loading operation.
public enum AsyncLoad<T: Equatable & Sendable>: Equatable, Sendable {
case none // Initial state
case loading // Loading in progress
case error(Error)// Loading failed with error
case loaded(T) // Successfully loaded with data
}Note: The generic type
Tmust conform to bothEquatableandSendableprotocols.
isLoading: Bool- Returns true if the state is.loadingitem: T?- Returns the loaded item if state is.loaded, nil otherwiseerror: Error?- Returns the error if state is.error, nil otherwise
import AsyncLoad
@Observable
class DataViewModel {
var userProfile: AsyncLoad<User> = .none
func loadUserProfile(id: String) async {
userProfile = .loading
do {
let user = try await userService.fetchUser(id: id)
userProfile = .loaded(user)
} catch {
userProfile = .error(error)
}
}
}For operations that need to track loading state but don't have any data to return, you can use AsyncLoad without a type parameter. This uses the NoContent type internally.
public struct NoContent: Equatable, Sendable
public typealias AsyncLoadNoContent = AsyncLoad<NoContent>This is useful for operations like:
- Delete operations
- Simple actions (e.g., mark as read, archive)
- Refresh operations without data
- Any operation where you only care about success/failure/loading state
import AsyncLoad
@Observable
class ActionViewModel {
var deleteStatus: AsyncLoad = .none // No type parameter needed
func deleteItem(id: String) async {
deleteStatus = .loading
do {
try await itemService.deleteItem(id: id)
deleteStatus = .loaded // Convenience property for NoContent
} catch {
deleteStatus = .error(error)
}
}
}You can also use the type alias explicitly:
var deleteStatus: AsyncLoadNoContent = .noneAn enhanced version of AsyncLoad that preserves cached data during loading and error states.
public enum CachedAsyncLoad<T: Equatable & Sendable>: Equatable, Sendable {
case none // Initial state
case loading(T? = nil) // Loading with optional cached data
case error(T? = nil, Error) // Error with optional cached data
case loaded(T) // Successfully loaded with data
}Note: The generic type
Tmust conform to bothEquatableandSendableprotocols.
isLoading: Bool- Returns true if the state is.loadingitem: T?- Returns the item from.loaded,.loading, or.errorstates, nil for.noneerror: Error?- Returns the error if state is.error, nil otherwise
import AsyncLoad
@Observable
class CachedDataViewModel {
var userProfile: CachedAsyncLoad<User> = .none
func loadUserProfile(id: String) async {
// Start loading while preserving any existing data
if case .loaded(let existingUser) = userProfile {
userProfile = .loading(existingUser)
} else {
userProfile = .loading()
}
do {
let user = try await userService.fetchUser(id: id)
userProfile = .loaded(user)
} catch {
// Preserve existing data even during error
let existingUser = userProfile.item
userProfile = .error(existingUser, error)
}
}
}A SwiftUI view component that automatically handles the display of different async states.
public struct AsyncLoadView<Item, Content: View, ErrorContent: View>: View// With custom error content
public init(
_ state: AsyncLoad<Item>,
@ViewBuilder content: @escaping (Item?) -> Content,
@ViewBuilder error: @escaping (Error) -> ErrorContent
)
// With default error content (Text)
public init(
_ state: AsyncLoad<Item>,
@ViewBuilder content: @escaping (Item?) -> Content
) where ErrorContent == Textimport SwiftUI
import AsyncLoad
struct UserProfileView: View {
@State private var viewModel = UserProfileViewModel()
var body: some View {
AsyncLoadView(viewModel.userProfile) { user in
if let user = user {
VStack(alignment: .leading) {
Text(user.name)
.font(.title)
Text(user.email)
.foregroundStyle(.secondary)
}
} else {
Text("No user data")
}
} error: { error in
VStack {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.red)
Text("Failed to load user: \(error.localizedDescription)")
.multilineTextAlignment(.center)
}
}
.task {
await viewModel.loadUserProfile(id: "123")
}
}
}A SwiftUI view component that handles cached async states with separate loading content.
public struct CachedAsyncLoadView<Item, Content: View, ErrorContent: View, LoadingContent: View>: Viewpublic init(
_ state: CachedAsyncLoad<Item>,
@ViewBuilder content: @escaping (Item) -> Content,
@ViewBuilder loading: @escaping (Item?) -> LoadingContent,
@ViewBuilder error: @escaping (Item?, Error) -> ErrorContent
)import SwiftUI
import AsyncLoad
struct CachedUserProfileView: View {
@State private var viewModel = CachedUserProfileViewModel()
var body: some View {
CachedAsyncLoadView(viewModel.userProfile) { user in
// Content view - only called when data is loaded
VStack(alignment: .leading) {
Text(user.name)
.font(.title)
Text(user.email)
.foregroundStyle(.secondary)
}
} loading: { cachedUser in
// Loading view - receives cached data if available
VStack {
if let cachedUser {
VStack(alignment: .leading) {
Text(cachedUser.name)
.font(.title)
Text(cachedUser.email)
.foregroundStyle(.secondary)
}
.opacity(0.5)
}
ProgressView()
}
} error: { cachedUser, error in
// Error view - receives cached data if available
VStack {
if let cachedUser {
VStack(alignment: .leading) {
Text(cachedUser.name)
.font(.title)
Text(cachedUser.email)
.foregroundStyle(.secondary)
}
.opacity(0.5)
}
Text("Error: \(error.localizedDescription)")
.foregroundColor(.red)
}
}
.task {
await viewModel.loadUserProfile(id: "123")
}
}
}- Type-safe: Generic enums ensure type safety for your data
- Equatable: All async state enums and their generic types conform to Equatable for easy state comparison
- Sendable: Full Swift 6 concurrency support with Sendable conformance for thread-safe async operations
- SwiftUI Integration: AsyncLoadView and CachedAsyncLoadView provide seamless integration with SwiftUI
- Error Handling: Built-in error state management
- Loading States: Automatic loading state handling with progress indicators
- Cached Data: CachedAsyncLoad preserves data during refreshes and errors
- Flexible UI: Customizable content and error views
- Use AsyncLoad for simple loading operations where you don't need to preserve data during refreshes
- Use CachedAsyncLoad when you want to preserve data during refreshes or show stale data during errors
- Ensure your data types conform to Equatable and Sendable - All generic types used with AsyncLoad components must implement both protocols
- Always handle all states in your UI to provide good user experience
- Use AsyncLoadView and CachedAsyncLoadView for simple cases to reduce boilerplate code
- Reset states appropriately (e.g., set to
.nonewhen appropriate)