swift-statable
AsyncValue パターンで Observable な状態管理を実現する Swift マクロ
Statable
English | 日本語
A declarative state management macro for SwiftUI. Combines the AsyncValue pattern with OperationTracker to manage asynchronous state in a type-safe manner.
Features
- 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 - Sendable Conformance: Full Strict Concurrency support
Quick Start
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)
}
}
Installation
Swift Package Manager
Add the following to your Package.swift:
dependencies: [
.package(url: "https://github.com/no-problem-dev/swift-statable.git", from: "1.0.2")
]
Add to your target:
.target(
name: "YourTarget",
dependencies: [
.product(name: "Statable", package: "swift-statable")
]
)
Usage
Basic Store
@Statable(UserProfile.self)
@MainActor @Observable
final class UserStore {
public init() {}
}
// Usage in View
struct ProfileView: View {
@Environment(UserStore.self) private var store
var body: some View {
switch store.state {
case .idle:
Text("No data")
case .loading(let previous):
VStack {
ProgressView()
if let prev = previous {
Text("Previous: \(prev.name)")
}
}
case .loaded(let profile):
Text("Hello, \(profile.name)")
case .failed(let error):
Text("Error: \(error.localizedMessage)")
}
}
}
Loading Data
// Basic load
await store.load {
try await api.fetchProfile()
}
// Load only if no value exists
await store.loadIfNeeded {
try await api.fetchProfile()
}
// Force reload
await store.reload {
try await api.fetchProfile()
}
Operation Tracking
enum DataOperation: String, CaseIterable, Sendable {
case fetch, save, delete
}
@Statable([Item].self, operations: DataOperation.self)
@MainActor @Observable
final class ItemStore {
public init() {}
}
// Tracking operations
struct ItemListView: View {
@Environment(ItemStore.self) private var store
var body: some View {
List {
if store.operations.isActive(.fetch) {
ProgressView("Loading...")
}
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))
}
}
}
API Reference
@Statable Macro
Generated Properties
| Property | Type | Description |
|---|---|---|
value |
T? |
Current value |
state |
AsyncState<T> |
State (for switch) |
isLoading |
Bool |
Whether loading |
isIdle |
Bool |
Whether idle |
isFailed |
Bool |
Whether failed |
hasValue |
Bool |
Whether value exists |
error |
StateError? |
Error |
operations |
OperationTracker<Op> |
Operation tracker (only with operations argument) |
Generated Methods
| Method | Description |
|---|---|
set(_:) |
Set value |
setError(_:) |
Set error |
startLoading() |
Start loading |
reset() |
Reset to initial state |
load(_:) |
Execute async operation |
loadIfNeeded(_:) |
Load only if no value |
reload(_:) |
Force reload |
AsyncState
public enum AsyncState<Value: Sendable>: Sendable {
case idle // Initial state
case loading(previous: Value?) // Loading (retains previous value)
case loaded(Value) // Load succeeded
case failed(StateError) // Load failed
}
OperationTracker
// Start/complete operations
operations.start(.fetch)
operations.complete(.fetch)
operations.fail(.fetch, with: error)
// Check state
operations.isActive(.fetch)
operations.hasActiveOperations
operations.error(for: .fetch)
// Convenience method
await operations.run(.fetch) {
try await api.fetchData()
}
StateError
public enum StateError: Error, Sendable, Equatable, Hashable {
case network(NetworkError)
case validation(ValidationError)
case notFound(resource: String)
case unauthorized
case server(code: Int, message: String)
case unknown(String)
}
// Convenience properties
error.localizedMessage // User-facing message
error.isRetryable // Whether retry is appropriate
// Convert from standard Error
let stateError = StateError(from: someError)
Design Principles
1 Store = 1 AsyncValue
Each Store manages a single type of async value. This ensures:
- State consistency
- Easy testing
- Clear responsibilities
SSOT (Single Source of Truth)
The AsyncState enum represents exclusive states, preventing contradictory states (e.g., isLoading = true AND error != nil) at the type level.
Previous Value During Loading
loading(previous: Value?) allows displaying the previous value during reload, improving UX.
Documentation
Detailed API documentation is available on GitHub Pages.
Dependencies
| Package | Purpose |
|---|---|
| swift-syntax | Macro implementation |
License
MIT License - See LICENSE for details.
同じカテゴリの OSS — UI / SwiftUI
swift-design-system
Swift PackageSwiftUI 向けの型安全で拡張可能なデザインシステム
swift-ui-routing
Swift PackageSwiftUI 向けの型安全で宣言的なルーティングライブラリ
swift-markdown-view
Swift PackageDesignSystem 統合とシンタックスハイライトを備えた SwiftUI ネイティブな Markdown レンダリング
swift-latex-view
Swift PackageSwiftUI ネイティブな LaTeX 数式レンダリング。LLM 出力にも堅牢
swift-cached-remote-image
Swift Packageメモリ & ディスクの二層キャッシュで高速表示する SwiftUI リモート画像
swift-google-slides-view
Swift PackageGoogle Slides API の JSON を SwiftUI で描画。A2A アーティファクトのストリーミングに対応
swift-document-scanner
Swift Package矩形検出・OCR・カメラ撮影を備えた iOS 向けドキュメントスキャン基盤
swift-voice-input
Swift Packageストリーミング認識とフローティングプレビュー UI を備えたプロトコル指向の音声入力