English | 日本語
Async loading state for SwiftUI, without writing it again in every store — one macro gives you the four states, and a slow earlier load can never overwrite a newer result.
- Declarative Macro: Reduce state management boilerplate with the
@Statablemacro - Exclusive State Representation: Type-safe expression of
.idle,.loading,.loaded,.failedwithAsyncState<T>enum - Operation Tracking: Track multiple concurrent operations individually with
OperationTracker - @Observable Integration: Fully integrated with SwiftUI's
@Observable - Main-actor by construction:
AsyncValueandOperationTrackerare@MainActor, so state transitions can never race with SwiftUI's reads — the compiler enforces it, not a comment - Last-started-wins: overlapping loads settle deterministically; a slow earlier load can never overwrite a newer result
- Cancellation is not failure: a cancelled load returns to the previous value instead of
showing an error or getting stuck in
loading
import SwiftUI
import Statable
// Simple Store definition
@Statable(MetabolicProfile.self)
@MainActor @Observable
final class ProfileStore {
public init() {}
var currentAge: Int { value?.age() ?? 0 }
}
// Store with operation tracking
enum WorkoutOperation: String, CaseIterable, Sendable {
case fetch, recordStrength, recordCardio
}
@Statable([WorkoutActivity].self, operations: WorkoutOperation.self)
@MainActor @Observable
final class WorkoutStore {
public init() {}
var isRecording: Bool {
operations.isActive(.recordStrength) || operations.isActive(.recordCardio)
}
}// Basic load
await store.load {
try await api.fetchProfile()
}
// Load only if the last load has not succeeded
await store.loadIfNeeded {
try await api.fetchProfile()
}Because value returns the previous value in loading and failed, one check covers "is
there anything to show", and a view renders exactly three faces:
if let value = store.value {
Content(value) // also during reload, also after a failure
if let error = store.error { Banner(error) } // failure does not take the screen away
} else if let error = store.error {
FailureFace(error) // only when there is nothing to show
} else {
Skeleton() // no answer yet
}Do not drive the view from isLoading alone: "is it loading" is not a reason to blank the screen.
enum DataOperation: String, CaseIterable, Sendable {
case fetch, save, delete
}
@Statable([Item].self, operations: DataOperation.self)
@MainActor @Observable
final class ItemStore {
public init() {}
}
struct ItemListView: View {
@Environment(ItemStore.self) private var store
var body: some View {
List {
ForEach(store.value ?? []) { item in
ItemRow(item: item)
}
}
.toolbar {
Button("Save") {
Task {
await store.operations.run(.save) {
try await api.saveItems(store.value ?? [])
}
}
}
.disabled(store.operations.isActive(.save))
}
}
}The full API reference and the guides live on GitHub Pages, including Design Principles for why the library is shaped this way.
Add the following to your Package.swift:
dependencies: [
.package(url: "https://github.com/no-problem-dev/swift-statable.git", from: "2.0.0")
]Add to your target:
.target(
name: "YourTarget",
dependencies: [
.product(name: "Statable", package: "swift-statable")
]
)| Package | Purpose |
|---|---|
| swift-syntax | Macro implementation |
MIT License - See LICENSE for details.