👏

Flow - ObservationとSwift 6で実現するSwiftUI状態管理ライブラリ

に公開

Flowの紹介

SwiftUIアプリの状態管理、どのように実装していますか?シンプルさを保ちながら、型安全性と構造化を両立させるのは簡単ではありません。Flowは、ObservationとSwift 6 Concurrencyを活用し、この課題に一つの答えを提供します。

SwiftUIの状態管理における2つの課題 💭

課題1:シンプルさと構造化のバランス

シンプルな実装の限界:

  • @Stateだけでは複雑なビジネスロジックに対応しづらい
  • コードがView内に散在し、テストが困難
  • 非同期処理やエラーハンドリングが煩雑に

構造化の代償:

  • 構造を求めると、ボイラープレートが増加
  • 学習コストが高くなる
  • 小〜中規模のアプリには過剰な場合も

求められるもの:

シンプルさを保ちながら、適度な構造化を実現できないか?

課題2:ViewModelパターンでの非同期処理の複雑さ

SwiftUIでViewModelを使おうとした時の典型的な実装:

// ObservableObject + Combine時代のパターン
class UserViewModel: ObservableObject {
    @Published var isLoading = false
    @Published var users: [User] = []
    @Published var errorMessage: String?

    private let api: UserAPI

    func loadUsers() {
        isLoading = true
        Task {
            do {
                let fetchedUsers = try await api.fetchUsers()
                // MainActorへの明示的な切り替えが必要
                DispatchQueue.main.async {
                    self.users = fetchedUsers
                    self.isLoading = false
                }
            } catch {
                DispatchQueue.main.async {
                    self.errorMessage = error.localizedDescription
                    self.isLoading = false
                }
            }
        }
    }
}

この実装での困りごと:

  • DispatchQueue.main.asyncをあちこちに書く必要がある(書き忘れると警告やクラッシュの原因に)
  • エラーハンドリングのたびに同じパターンを繰り返す
  • isLoadingのセットし忘れでローディング状態が残り続ける
  • テストで非同期処理とUI更新のタイミングを検証するのが困難

求められるもの:

ViewModelパターンを使いつつ、MainActorの管理を自動化して、安全に非同期処理とState更新を書けないか?

Flowの答え ✨

Flowは、ObservationとSwift 6 Concurrencyを活用し、これらの課題に答えます:

問い:シンプルさと構造化の両立は?
答え:単方向データフローとFeature単位の明確な構造、最小限のボイラープレート

問い:ViewModelでの非同期処理とMainActor管理は?
答え:MainActor隔離により、非同期処理内で直接State変更が可能。Swift 6のコンパイル時チェックで、データ競合をコンパイル時に検出

Flowの5つのコア原則 🔍

1. 単方向データフロー:予測可能な状態管理

Flowは、ReduxやReSwiftに影響を受けた単方向データフローを採用しています:

流れの説明:

  1. View - ユーザーイベント(ボタンタップなど)が発生
  2. Action - イベントを表すActionをStoreに送信(store.send(.increment)
  3. Handler - ActionHandlerがActionを処理し、Stateを変更
  4. State - Stateの変更がViewに自動的に反映(@Observable
  5. View - UIが更新され、新しいStateを表示
// 1. Viewでイベント発生
Button("Load") {
    store.send(.load)  // 2. Actionを送信
}

// 3. HandlerでAction処理
ActionHandler { action, state in
    switch action {
    case .load:
        state.isLoading = true  // 4. State変更
        return .run { state in
            let data = try await api.fetch()
            state.data = data  // 4. State変更
        }
    }
}

// 5. Viewが自動更新(@Observableのおかげ)
if store.state.isLoading {
    ProgressView()
}

メリット:

  • 予測可能 - データの流れが一方向で追いやすい
  • デバッグしやすい - どのActionがどのStateを変更したか明確
  • テストしやすい - 入力(Action)と出力(State)が明確

2. ツリー構造はViewのみ:SwiftUIの哲学に沿った設計

SwiftUIでは、ツリー構造を持つのはViewだけです:

NavigationStack (View)
  └─ ListScreen (View)
       └─ DetailScreen (View)

親View → 子View → 孫Viewという階層はありますが、Stateは各Viewが@Stateでローカルに保持します。

多くの状態管理ライブラリの問題:
Storeもツリー構造にしようとします(親Store → 子Store → 孫Store)。これはSwiftUIの哲学から外れ、複雑さを増大させます。

Flowのアプローチ:
SwiftUIの標準に従い、各Viewが独立したStoreを持ちます。Store同士に親子関係はありません:

struct UserListView: View {
    // このViewが独立したStoreを持つ
    @State private var store = Store(
        initialState: UserFeature.State(),
        feature: UserFeature()
    )

    var body: some View {
        List(store.state.users) { user in
            Text(user.name)
        }
        .onAppear {
            store.send(.load)
        }
    }
}

メリット:

  • SwiftUIの標準に沿う - ツリー構造はViewのみ
  • シンプル - Store階層の管理が不要
  • ライフサイクルが明確 - StoreはViewと連動
  • テストが独立 - 各Featureを個別にテスト可能
  • メモリ効率的 - Viewが消えればStoreも解放

3. Result-Oriented:関数的な明快さ

struct TodoFeature: Feature {
    @Observable
    final class State {
        var todos: [Todo] = []
    }

    enum Action: Sendable {
        case save(title: String)
    }

    enum ActionResult: Sendable {
        case saved(id: String)
    }

    func handle() -> ActionHandler<Action, State, ActionResult> {
        ActionHandler { action, state in
            switch action {
            case .save(let title):
                return .run { state in
                    let todo = try await api.create(title: title)
                    state.todos.append(todo)
                    return .saved(id: todo.id)
                }
            }
        }
    }
}

// View側
Button("Save") {
    Task {
        let result = await store.send(.save(title: title)).value
        if case .success(.saved(let id)) = result {
            await navigator.navigate(to: .detail(id: id))
        }
    }
}

メリット:

  • アクションは値を返す - 関数的な明快さ
  • 親Viewが制御できる - ナビゲーション、通知などを上位で決定
  • 副作用の責任が明確 - どこで何が起きるかが追いやすい
  • 型安全なコントラクト - 結果の型が明示的で、コンパイル時にチェック

4. MainActor隔離:非同期処理内での安全なState変更 ⭐️

Flowでは、非同期処理内で直接Stateを変更できます:

case .fetchUser:
    state.isLoading = true
    return .run { state in
        // 非同期処理内でStateを直接変更!
        let user = try await api.fetchUser()
        state.user = user
        state.isLoading = false
    }
    .catch { error, state in
        state.isLoading = false
        state.error = error
    }

メリット:

  • コードの局所性 - ローディング開始からエラーハンドリングまで1箇所に
  • 直感的 - 通常のSwiftコードと同じ感覚で書ける
  • コンパイル時安全性 - データ競合はコンパイルエラーで検出

5. SwiftUI標準のObservation

@Observable
final class State {
    var count = 0
    var isLoading = false
    var errorMessage: String?
}

メリット:

  • Combine依存なし - SwiftUIの標準機能のみ
  • 最適化を享受 - SwiftUIの差分検知、パフォーマンス向上
  • プラットフォームと協調 - Appleの進化と共に成長
  • 学習コスト削減 - SwiftUI開発者にとって自然

おわりに 🌟

SwiftUIの状態管理は、ObservationとSwift 6 Concurrencyによって新しいステージに入りました。

Flowは、ObservationとSwift 6 Concurrencyを前提とすることで、シンプルで安全な設計を実現しました。特にMainActor隔離による非同期処理内での直接State変更は、従来の間接的なアプローチとは異なる特徴的な機能です。

Flowは以下のライブラリと思想から学びました:

  • Redux - 単方向データフローの明快さ
  • ReSwift - SwiftでのRedux実装の先駆者
  • The Composable Architecture - 型安全な状態管理パターン

先人たちの知見の上に、Observation時代の新しいアプローチを構築しています。

Flowは、ObservationとSwift 6 Concurrencyを前提とした状態管理ライブラリです。

プロジェクトの要件、チームの特性、技術的な制約、将来のビジョンを考慮して、あなたのプロジェクトに合った選択をしてください。

次のステップ 🚀

Flowに興味を持っていただけましたか?

フィードバックや質問があれば、GitHubでお待ちしています。

この記事は2025年11月に執筆されました。技術情報は執筆時点のものです。

Discussion