SwiftUIの状態管理をマクロで仕組み化する
目次
- はじめに
- State層の設計思想
- AsyncState:排他的状態表現
- @Statableマクロによる自動化
- EnvironmentによるDI統合
- OperationTrackerによる操作追跡
- アーキテクチャへの統合
- まとめ
1. はじめに
本記事では、SwiftUIの状態管理をマクロで仕組み化するライブラリswift-statableを紹介します。
本記事は「サーバーサイドSwiftでiOSアプリ開発をどこまで効率化できるか」シリーズの第2回です。前回はSwift Configurationを取り上げました。今回はマクロを使った状態管理の仕組み化を扱います。
また、以前の記事で「ViewModelを使わないアーキテクチャ」を提唱しました。そこで登場したState層(ビジネスロジックを持たず、状態を保持する責務に特化した層)を、パッケージとして論理化・仕組み化したものがswift-statableです。
2. State層の設計思想
以前の記事で提唱したState層は、以下の特徴を持ちます。
- ビジネスロジックを持たない:状態の保持と更新メソッドの提供のみ
- Domainのみに依存:UseCasesやRepositoryには依存しない
- 「状態を保持する箱」に徹する:ViewModelのような複合的な責務を持たない
// State層の例(以前の記事より)
@MainActor
@Observable
public final class UserState {
public private(set) var user: User?
public func setUser(_ user: User?) {
self.user = user
}
}
この設計自体は十分に機能していましたが、次のような観点からパッケージ化を進めました。
- 設計思想の明確化:State層が「何であるか」を型システムで表現する
- 状態表現の標準化:非同期ロード状態(idle/loading/loaded/failed)を統一的に扱う
- 再利用可能な仕組みの提供:プロジェクトをまたいで同じパターンを適用する
swift-statableは、State層の設計思想をマクロと型で仕組み化したライブラリです。
3. AsyncState:排他的状態表現
swift-statableの核心は、AsyncState enumによる排他的な状態表現です。
従来のViewModel的な実装では、複数の独立した変数で状態を管理することがあります。
// 従来のアプローチ
@Observable
class UserViewModel {
var user: User?
var isLoading = false
var error: Error?
}
この設計には、状態の不整合が起こりうるという問題があります。たとえば、isLoading = true と user != nil が同時に成立する状態は、意図したものでしょうか。リロード中なのか、バグなのか、コードを読むだけでは判断できません。
AsyncStateは、4つの状態を排他的に表現するenumです。
public enum AsyncState<Value: Sendable>: Sendable {
case idle // 初期状態
case loading(previous: Value?) // ロード中
case loaded(Value) // ロード成功
case failed(StateError) // ロード失敗
}
enumであるため、これらの状態は同時に成立しません。この設計により、「ロード中かつ成功済み」のような不整合状態は型レベルで排除されます。
loading ケースは previous: Value? を保持するため、リロード中も前回の値を表示し続けるUXが実現できます。
switch store.state {
case .loading(let previous):
VStack {
ProgressView()
if let user = previous {
// リロード中も前回のデータを表示
UserRow(user: user).opacity(0.5)
}
}
case .loaded(let user):
UserRow(user: user)
// ...
}
エラーもStateErrorとして構造化されており、isRetryableプロパティでリトライ可能かを判定できます。URLErrorからの自動変換もサポートしています。
4. @Statableマクロによる自動化
@Statableマクロは、AsyncStateを内部で使用するStoreクラスを簡潔に定義します。
@Statable(UserProfile.self)
@MainActor @Observable
final class ProfileStore {
nonisolated public init() {}
}
これだけで、以下のプロパティとメソッドが自動生成されます。
| 種類 | 名前 | 説明 |
|---|---|---|
| プロパティ | value |
現在の値(UserProfile?) |
| プロパティ | state |
状態(AsyncState<UserProfile>) |
| プロパティ | isLoading |
ロード中かどうか |
| プロパティ | hasValue |
値が存在するか |
| プロパティ | error |
エラー(StateError?) |
| メソッド | set(_:) |
値を設定 |
| メソッド | load(_:) |
非同期操作を実行 |
| メソッド | loadIfNeeded(_:) |
値がない場合のみロード |
| メソッド | reload(_:) |
強制リロード |
| メソッド | reset() |
初期状態にリセット |
マクロは内部的にAsyncValue<T>をラップするコードを生成し、StatableとSendableプロトコルへの準拠も追加します。
await store.load {
try await api.fetchProfile()
}
loadメソッドは startLoading() → API呼び出し → 成功時は set() / 失敗時は setError() という状態遷移を自動的に処理します。
5. EnvironmentによるDI統合
swift-statableで定義したStoreは、SwiftUIの@Environmentと統合できます。@Entryマクロを使うと、KeyPathベースでStoreにアクセスできます。
extension EnvironmentValues {
@Entry public var profileStore: ProfileStore = .init()
}
@Entryマクロを使う理由は、デフォルト値を設定できる点にあります。@Environment(ProfileStore.self)のような型ベースのアクセスでは、.environment()での注入を忘れるとランタイムクラッシュが発生します。@Entryで登録しておけば、注入忘れがあってもデフォルト値が使われるため安全です。
また、KeyPath構文(\.profileStore)はSwiftUIの標準的なEnvironment(\.dismiss、\.colorSchemeなど)と一貫性があり、コードの可読性も向上します。
struct ProfileView: View {
@Environment(\.profileStore) private var store
var body: some View {
Group {
switch store.state {
case .idle:
Text("データ未取得")
case .loading(let previous):
ProgressView()
if let profile = previous {
Text("更新中... \(profile.name)")
}
case .loaded(let profile):
Text("こんにちは、\(profile.name)さん")
case .failed(let error):
VStack {
Text(error.localizedMessage)
if error.isRetryable {
Button("再試行") {
Task { await store.reload { try await api.fetchProfile() } }
}
}
}
}
}
.task {
await store.loadIfNeeded { try await api.fetchProfile() }
}
}
}
注入側も同様にKeyPath構文を使用します。
@main
struct MyApp: App {
@State private var profileStore = ProfileStore()
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.profileStore, profileStore)
}
}
}
複数のStoreを組み合わせる場合も、それぞれ@Entryで登録し、.environment(\.keyPath, value)で注入します。各Storeは独立したAsyncValueを持ち、それぞれの状態を排他的に管理します。
6. OperationTrackerによる操作追跡
リストのCRUD操作など、主データの状態とは別に個別の操作を追跡したい場合があります。OperationTrackerはこのケースに対応します。
- AsyncValue:リスト全体の取得・表示状態を管理
- OperationTracker:個別アイテムの保存・削除などの操作を追跡
これにより、「リストは表示中だが、特定アイテムを削除中」といった状態を表現できます。
@Statableにoperationsパラメータを追加することで、OperationTrackerが自動生成されます。
enum ItemOperation: String, CaseIterable, Sendable {
case fetch, save, delete
}
@Statable([Item].self, operations: ItemOperation.self)
@MainActor @Observable
final class ItemStore {
nonisolated public init() {}
}
operationsプロパティを通じて操作を追跡します。
// 操作中かどうかを確認
if store.operations.isActive(.save) {
ProgressView()
}
// 操作を自動追跡しながら実行
await store.operations.run(.save) {
try await api.saveItems(store.value ?? [])
}
// 特定操作のエラーを取得
if let error = store.operations.error(for: .delete) {
Text("削除に失敗: \(error.localizedMessage)")
}
7. アーキテクチャへの統合
swift-statableは、以前の記事で提唱したアーキテクチャのState層を置き換えます。
以前の記事では、State層を次のように手動で実装していました。
@MainActor
@Observable
public final class UserState {
public private(set) var user: User?
public private(set) var isLoading = false
public private(set) var error: Error?
public var displayName: String { user?.name ?? "ゲスト" }
public func setUser(_ user: User?) {
self.user = user
self.error = nil
}
public func setLoading(_ isLoading: Bool) {
self.isLoading = isLoading
}
public func setError(_ error: Error?) {
self.error = error
self.isLoading = false
}
}
swift-statableを使うと、これが次のように簡潔になります。
@Statable(User.self)
@MainActor @Observable
final class UserStore {
nonisolated public init() {}
public var displayName: String { value?.name ?? "ゲスト" }
}
手動実装ではuser、isLoading、errorを個別に管理していたため、状態の不整合が起こりえました。swift-statableではAsyncStateによる排他的な状態表現が組み込まれ、「ロード中に前回の値を保持する」「エラーのリトライ可否を判定する」といった機能も標準で提供されます。
Storeの責務は変わりません。ビジネスロジックを持たず、状態を保持する箱に徹します。Viewは@EnvironmentでStoreにアクセスし、UseCaseを呼び出して結果をStoreに反映するという構造も同じです。
8. まとめ
本記事では、SwiftUIの状態管理をマクロで仕組み化するライブラリ「swift-statable」を紹介しました。
swift-statableは、「State層はビジネスロジックを持たず、状態を保持する箱に徹する」という設計思想を、マクロと型で仕組み化したライブラリです。AsyncState enumによる排他的な状態表現で不整合を型レベルで防止し、@Statableマクロでプロパティやメソッドを自動生成します。@Entryマクロと組み合わせることで、SwiftUIのEnvironmentにも自然に統合できます。
プロジェクトをまたいで同じ設計思想を適用したい場合に活用できます。
Discussion