iOSエンジニアリング

[iOSアーキテクチャ #7] TCAと一方向データフロー

SwiftUI時代のアーキテクチャ議論で欠かせない名前があります。Point-Freeが開発したTCA(The Composable Architecture)です。

読了 5 分
[iOSアーキテクチャ #7] TCAと一方向データフローのカバー画像

SwiftUI時代のアーキテクチャ議論で欠かせない名前があります。Point-Freeが開発した**TCA(The Composable Architecture)**です。

これまで扱ったMVVM(Model-View-ViewModel)やクリーンアーキテクチャが「役割をどう分けるか」という話だったとすれば、TCAの問いは異なります。「状態が変化するすべての経路を、1つのルールで制御できないか?」

今回は、TCAの一方向データフローがどう動くのか、そして何を得て何を支払うのかを整理します。


3つの材料:State、Action、Reducer

TCAでは、1つの画面(1つの機能)を3つの要素で定義します。

  • State:この機能のすべての状態を保持する1つの構造体
  • Action:この機能で起こり得るすべてのイベントを含むenum。ボタンタップ、レスポンスの到着、通知の受信まですべてです
  • Reducer:「現在のStateにこのActionが届いたら、Stateはどう変化するか」を定義する純粋関数
@Reducer
struct Counter {
    @ObservableState
    struct State: Equatable {
        var count = 0
    }
    enum Action {
        case incrementTapped
        case decrementTapped
    }
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .incrementTapped:
                state.count += 1
                return .none
            case .decrementTapped:
                state.count -= 1
                return .none
            }
        }
    }
}

Viewは状態を直接変更できません。できるのはStoreにActionを送ることだけです。StoreがReducerを実行してStateを変更すると、Viewは変更後のStateを描画します。

사건 발생 → Action 전송 → Reducer가 State 변경 → View 갱신

この一方向の循環がすべてです。状態が変わる抜け道はありません。「この値はいったいどこで変わったのか?」というデバッグの迷宮が、構造的に消えます。


副作用もルールの中へ

ネットワークリクエストのような非同期処理はReducerが直接実行せず、Effectとして返します。Effectが完了すると、結果はActionになって戻ってきます。

case .refreshTapped:
    state.isLoading = true
    return .run { send in
        let posts = try await postClient.fetch()
        await send(.postsLoaded(posts))
    }

case .postsLoaded(let posts):
    state.isLoading = false
    state.posts = posts
    return .none

postClientのような依存性はTCAの依存性注入システムで差し込むため、テストではモッククライアントに置き換えられます。ここでTCA最強の武器が登場します。TestStoreです。

let store = TestStore(initialState: Feed.State()) { Feed() }
await store.send(.refreshTapped) { $0.isLoading = true }
await store.receive(.postsLoaded(mockPosts)) {
    $0.isLoading = false
    $0.posts = mockPosts
}

各Actionに対してStateがどう変化すべきかをすべて明示するよう強制します。予期しない状態変化が1つでもあればテストは失敗します。このレベルの完全性検証は、MVVMのテストでは再現しにくいものです。

TestStoreは、状態変化を1つずつすべて明示するよう強制します
TestStoreは、状態変化を1つずつすべて明示するよう強制します

代償は何でしょうか?

この精密さには対価が伴います。

**学習コストが高いことです。**生産性が出る前に、Reducer、Effect、Store、Scope、依存性システムなど多くの概念を学ぶ必要があります。関数型プログラミングの経験がないチームなら、なおさらです。

**ボイラープレートがあります。**値を1つ変えるだけでも、Actionのケースを作り、Reducerに分岐を追加しなければなりません。単純な画面では、この儀式は過剰です。

**サードパーティ依存であることです。**TCAはAppleではなくPoint-Freeが開発するライブラリです。毎年変化するSwiftUIに合わせて、TCAも何度も大規模なマイグレーションを経験してきました。アプリのすべての画面がこのライブラリの上に構築されることになります。活発にメンテナンスされている成熟したプロジェクトですが、アプリの骨格を外部に委ねる判断は軽くありません。


どんなチームに向いているのでしょうか?

TCAが価値を発揮する条件は、比較的はっきりしています。

  • 共同編集ドキュメント、金融、複雑なフォーム、リアルタイム同期など、状態が複雑に絡み合うアプリ
  • 状態変化の完全なテストが重要なドメイン
  • 関数型の概念に慣れている、または学習に投資できるチーム

一方、アプリの大半の画面が「受け取って表示する」だけなら、第3回で扱ったMV(Model-View)/MVVMにUseCaseを加えるだけで十分な場合が多いでしょう。TCAは間違った選択肢ではなく、コストの高い選択肢です。そのコストを状態の複雑さが正当化するときにこそ、TCAは輝きます。

TCAは間違った選択肢ではなく、コストの高い選択肢です
TCAは間違った選択肢ではなく、コストの高い選択肢です

まとめ

  • TCAはState・Action・Reducerで機能を定義し、状態変更の経路を1つの一方向サイクルに強制します。
  • 副作用もEffect → Actionとしてルールの中に入り、TestStoreが状態変化の完全性を検証します。
  • 代償は、学習コスト、ボイラープレート、そしてアプリの骨格をサードパーティに委ねる判断です。
  • 状態の複雑さが高いアプリほどメリットが大きくなる、明確なトレードオフを持つアーキテクチャです。

シリーズ最後の問いだけが残っています。MVCからTCAまで、数多くの選択肢の中で**私たちのチームは何を選ぶべきでしょうか?**次回は選択基準を整理し、シリーズを締めくくります。

あわせて読みたい