Skip to content

Latest commit

 

History

History
258 lines (194 loc) · 6.92 KB

File metadata and controls

258 lines (194 loc) · 6.92 KB

AsyncState

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.

Problem Solved

Asynchronous UI code often ends up spreading state across multiple booleans and optional values:

  • isLoading
  • error
  • items
  • isRefreshing

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.

Why Use AsyncState

  • 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 Equatable when its generic parameters are Equatable.
  • It centralizes common transitions such as starting a refresh or restoring a non-loading state.

Installation

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")
    ]
)

State Model

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 .success and .refreshing.
  • error: Returns the error for .error.
  • isLoading: Returns true for .loading and .refreshing.
  • isError: Returns true for .error.

State Transitions

AsyncState includes focused mutation helpers for common flows.

startLoading():

  • .notLoaded becomes .loading
  • .error becomes .loading
  • .success(value:) becomes .refreshing(value:)
  • .refreshing(value:) stays .refreshing(value:)

stopLoading():

  • .loading becomes .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:)

Simple ViewModel Example

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")
    }
}

Refresh Example

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")
        ]
    }
}

UI or Screen-State Usage Example

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)
            }
        }
    }
}

API

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)
}

Design Choices

  • Value and Failure are constrained to Equatable so 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 .refreshing when 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.

Tests

The package includes XCTest coverage for:

  • every enum case
  • computed properties
  • state transition helpers
  • equality behavior

Run the test suite with:

swift test

License

This package is released under the MIT License. See LICENSE.