💡

個人的な Effective TCA

2023/10/02に公開

TCA はいくつかの API によって、コードの書き方にある程度の制限を設けてはくれますが、それでも正しい形で活用しないとコードはすぐに読みづらくなっていくと感じています。
そして、今まで TCA を使ってきたり、数多くの Discussion を見たりしてきて、TCA の利用におけるプラクティスを抑えておくことは、TCA を使ったコードの設計を良い状態に保つことに繋がると思っているため、自分が開発している時に意識している何点かを本記事に書いておこうと思います。(あわよくば、この記事を誰かに説明する時に利用しようと思っています)

あくまで、個人的なものなのでプロジェクトによっては適さない場合があります。
また、思いついたことがあったり、何か新しいことがわかった際には追記していこうと思います。
列挙している順序について特に意味はありません。

プラクティス集

Action は「何が起こったか」ベースで命名すること

これは TCA の Discussion で話されているもので、個人的にはこれを意識するだけでもコードの見通しやテストのしやすさが非常に改善すると感じており、強く推奨したいプラクティスです。

https://github.com/pointfreeco/swift-composable-architecture/discussions/1666#discussioncomment-4140594

例えば、非常にシンプルな例として「カウントアプリ」があった場合に、その機能の Action 名を incrementCount と命名するのではなく、incrementButtonTapped と命名した方が良いという話です。
この例では、incrementCount は「何をしたいか」を表す命名になっていることに対して、incrementButtonTapped は「何が起こったか」を表す命名になっています。

このように、「何が起こったか」ベースで命名すると色々良いことがあって、詳しくは Discussion の中でも話されていますが、

  1. incrementButtonTapped Action のハンドリングで追加の要件が発生した場合に、柔軟に対応できる
  2. Action の数を減らすことに繋がる
  3. テストが書きやすくなる・読みやすくなる

一つずつ簡単に説明します。

まず 1 ですが、例えば Action を incrementCount としてしまった場合、この Action は文字通り countincrement する処理しか相応しくないものとなってしまいます。
もし incrementButtonTapped と命名していたのであれば、将来「increment button がタップされた時に API リクエストしたい」という要件が仮に追加された場合、incrementButtonTapped は increment button がタップされたということしか表していないため、API リクエストの処理をこの Action のハンドリングに追加しても不思議ではない構造になっていると思います。
このように、一つの Action で表現できる処理が柔軟になり、変更に強い設計にしやすいと考えられます。

次に 2 ですが、これも先ほどと同じ例を用いると、

  • 「何をしたいか」ベースで命名した場合、incrementCountrequestForCountAPI のような二つの Action を定義する必要がある
  • 「何が起こったか」ベースで命名した場合、incrementButtonTapped のみで十分

という違いがあります。
例としてはシンプルすぎてメリットが掴みづらいかもしれないですが、この話は機能が複雑になればなるほど重要性が増していくものだと思っています。
また、TCA で Action を send するコストは低いものではないため、不要な Action を減らす機会になるというメリットもあります。

最後に 3 ですが、「何が起こったか」ベースで Action を作ることを徹底すれば、テストも書きやすくなったり読みやすくなったりするメリットがあります。
例えば、少しあり得ない話ですが increment button がタップされた際に、

  • count が 1 増える
  • API リクエストが行われる
  • タイマーが動き出す

のような処理を行いたい場合、「何をしたいか」ベースで命名してしまうと、これら三つに対応する Action を定義する必要があります。
もし三つ定義した場合、例として以下のようなコードになると考えられます。

await store.send(.incrementCount)
await store.receive(.requestAPI)
await store.receive(.startTimer)
await store.receive(.apiResponse) // API リクエストのレスポンスを受け取った
await store.receive(.timerTicked) // タイマーが指定の秒数動いた

これを「何が起こったか」ベースで Action を作るようにした場合、以下のようにできそうです。

await store.send(.incrementButtonTapped)
await store.receive(.apiResponse)
await store.receive(.timerTicked)

「何をしたいか」ベースで命名した場合、テストコードを書く際に実装の詳細がテストに漏れ出しすぎてしまい、テストを読み書きするコストが高いと思います。
一方で、「何が起こったか」ベースで命名した場合は、「ユーザーイベントである increment button がタップされたことをきっかけに、二つのイベントが起こっている」のようにある程度イベントの流れを捉えやすくなり、テストを書く手間も減少することが期待できそうです。

この辺り、自分自身でも例として微妙なものを出してしまっている自覚があるため、気力があるタイミングで例をわかりやすいものに修正したいと思っています🙇‍♂️

Action は使い回さずに、共通利用するロジックは function に切り出すこと

こちらについては、以前関連する記事を書きました。

https://zenn.dev/kalupas226/articles/c4128a0f99d07b#sharing-logic-with-actions

↑ の記事の「Sharing logic with actions」が伝えたいことの全てなので、本記事では割愛します。
先ほどの「Action は「何が起こったか」ベースで命名すること」でも説明したように、TCA における Action の send が低コストではないことは、この話でも共通で認識しておきたいことです。

Delegate Action を活用すること

こちらについても、以前関連する記事を書きました。

https://zenn.dev/kalupas226/articles/e214cf384a7b84

↑ の記事では、TCAFeatureAction と呼ばれる View / Internal / Delegate 三種の Action を紹介していますが、その中でも Delegate Action は特に重要だと考えています。

Delegate Action が何なのかについては上記の記事を参照して頂ければと思いますが、Delegate Action は TCA 公式のチュートリアルでも採用されていたり、TCA の Examples の中では比較的新しい SyncUps でも採用されていたり、多くの Discussion で Point-Free の中の方達が推奨している姿勢を見せてくれていたり、採用する価値は高いと思っています。

TCA はドメイン (Reducer) を小さく分割して結合することを強みとしており、分割したドメイン間のコミュニケーションも Action を通じることで容易に実現できる一方で、分割していけばいくほどドメイン間のコミュニケーションに利用される Action の見通しは悪くなっていきがちです。
そのため、「ドメイン間のコミュニケーション」という目的で利用される Delegate Action の namespace は分けるようにしておくと、コードの見通しが良くなっていくと感じています。

TCA 1.7 では View のための Action を表現するための ViewAction が追加され、Discussion で紹介されている ViewAction を手軽に実現できるようになりました。

https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#View-actions

Effect.send は極力利用しないこと

Effect.send は Reducer 内で別の Action を発火させる際に便利なので、もしかしたら頻繁に利用してしまっている方もいるかもしれません。

しかし、TCA の Effect.send のドキュメントコメントをよく見てみると「Effect.send はなるべく利用しないようにすること」に近い旨が記載されています。

https://github.com/pointfreeco/swift-composable-architecture/blob/dcde72151de8a60eecaa1673ed3c3d110549069a/Sources/ComposableArchitecture/Effect.swift#L125-L136

このコメントには、ここまでに紹介した二つの話が関係していることがわかります。

  • ロジック共有の用途で Effect.send を利用しないこと (その場合は function を利用すること)
  • Effect.send は Delegate Action のみの利用に制限すること

Effect.send は Action を簡単に発火することができ便利ですが、おおよそのケースで代替手段が存在します。
例えば、わざわざ Effect.send して新しい Action を発火するのではなく、前の Action のハンドリングで必要な処理を済ませたり、前述したように function を切り出してそれを利用するなどの手段があります。

Effect.send は Delegate Action に絞って活用するようにして、コードの見通し・パフォーマンスを向上させるようにしましょう。

Parent Reducer から Child Action を呼ばないこと

こちらについても、以前関連する記事を書きました。

https://zenn.dev/kalupas226/articles/87b1f7b245915c

これについても、説明したい内容は上記の記事にほとんど書いてあるため、本記事では割愛しようと思います。

関連する話として、最近 Performance のドキュメントに、Parent Reducer から Child Action を呼ぶための方法として reduce を直接呼ぶ方法が追記されていました。
この方法は Performance の問題は改善できるものの、「何が起こったか」という命名から外れた Action を増やすことにつながってしまうため、個人的には避けたいなと感じています。
この方法を推奨している理由については近いうちに Discussion などで聞いてみたいと思っています。

Tree-based・Stack-based navigation のための API を活用すること

Tree-based・Stack-based navigation が何なのかについては、以下の二つの記事で説明しているため、そちらを参照して頂ければと思います。

https://zenn.dev/kalupas226/articles/e5a010f7858796

https://zenn.dev/kalupas226/articles/98f8118b218cd0

TCA では Tree-based / Stack-based navigation のために、それぞれ PresentationReducer / StackReducer という仕組みが用意されています。
これらを利用せずとも Navigation は表現できますが、これらの API を利用することで、

  • 無駄の少ない形で Navigation の状態管理ができる
  • 統一された API で Navigation が表現できる
  • うまく使い分ければマルチモジュールの循環参照問題を解決できる
  • Effect のキャンセル処理をやってくれる
  • @Dependency(\.dismiss) が利用できるようになる

など様々なメリットがあるため、TCA を利用しているのであれば、Navigation API を利用しない手はないと思います。

また、特に PresentationReducer を利用する場合においては、State に何個も @PresentationState を分けて定義するのではなく、enum で Navigation の State を管理して、無駄のない状態にしましょう。
この話についての詳細は以下に記載されています。

https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/treebasednavigation#Enum-state

Reducer を分割・結合するための方法を理解して利用すること

TCA は前述したように、ドメインを細かく分割しやすく結合しやすい構造にできることを強みとしているため、そのための API をしっかり理解して、適切に Reducer を分割できるようになっておくことが重要だと感じています。
TCA に慣れてからは、ある程度分割方針などは固まってくる気はしますが、慣れていない場合そもそもどのような分割・結合方法があるのか、どのように分割・結合すれば良いのかという点で悩むことも多い気がしています。

TCA にはいくつかの Reducer 分割・結合のための API が存在しているため、それをざっと把握しておくと、分割・結合の方針みたいなものが見えてくると思います。
そのため、ここでは TCA の Reducer 分割・結合のための API を簡単に紹介します。

  • Scope
  • ifLet (optional な状態のためのもの)
  • forEach (IdentifiedArray を利用した状態のためのもの)
  • ifLet (PresentationState のためのもの)
  • forEach (StackState のためのもの)

結構種類があって混乱する方もいると思うので、少しずつ紹介します。

Scope

まず Parent domain に Child domain を埋め込むための最も基本的な API である Scope です。

これはドキュメントの例にあるように、optional ではない Child domain を Parent domain に埋め込むことに適しています。
例えば、ある画面があったとして、その画面の Header がやたら複雑みたいな話の時に、以下のように Scope を使って domain を分割し、結合できます。

@Reducer
struct SomeReducer {
  @ObservableState
  struct State: Equatable {
    var header: Header.State
    // ...
  }
  
  enum Action {
    case header(Header.Action)
    // ...
  }
  
  var body: some ReducerOf<Self> {
    Scope(state: \.header, action: \.header) {
      Header()
    }
    Reduce { state, action in 
      // 自身の Reducer の処理
    }
  }
}

ifLet (optional な状態のためのもの)

次に optional な状態のための API である ifLet です。
後述の @PresentationState のための ifLet もあり、少し紛らわしい部分がありますが、単純な optional な状態のために利用できるのがここで説明する ifLet です。

この ifLet は、ある時は optional である可能性があるような Child domain を Parent domain に埋め込む際に利用します。
例えば「画面の表示時は必要な状態が取得できてないため描画できないが、API 通信の結果必要な状態が取得できたので、描画できるようになった domain」などを表現するのに適しています。
TCA の Example では、トグルボタンによって Parent View に埋め込まれている Counter View の State に値が入ったり nil になったりして、Counter View が表示されたり表示されなくなったり、という挙動を表現する際に ifLet を利用しています。

forEach (IdentifiedArray を利用した状態のためのもの)

次に IdentifiedArray を利用した状態のための forEach です。
この forEach にも StackState のための別の同名の API が存在しているため、少し紛らわしいかもしれません。

forEach については、TCA の Examples でいうと Todos というアプリがわかりやすいと思います。

この Todos は TODO 管理アプリみたいなものなのですが、Todo 一つ一つを表現するための Todo という Child domain があって、Todo List 全体を管理するための Todos という Parent domain があり、Parent domain に複数の Child domain を埋め込んでいるという状態になっています。
要するに、「大きな List 画面を管理する Reducer と List 内の一つ一つの Row を管理するための Reducer を分けたい」みたいなシーンの時に利用することが多いです。

ifLet (PresentationState のためのもの)

次に @PresentationState のための ifLet です。
これが先ほど紹介した optional な状態のための ifLet と同じ命名になっているのは、@PresentationState も optional で状態を表現するためだと思います。(詳しくは TCA の Navigation ドキュメントを参照)

この ifLet は前述の Tree-based navigation を表現するための API となっており、そのドキュメントを参照して頂けるのが一番わかりやすいと思います。

forEach (StackState のためのもの)

最後は StackState のための forEach です。
こちらも先ほど紹介した IdentifiedArray のための forEach と同じ命名になっていますが、この forEach に利用する StackStateIdentifiedArray と同様に Collection として表現されているため同じ命名になっているのだと思います。

この forEach は前述の Stack-based navigation を表現するための API となっており、こちらもそのドキュメントを参照して頂けるのが一番わかりやすいと思います。

TCA で domain を分割・結合するための方法は数種類あることを利用例を交えながら説明しましたが、これらの API をざっと理解しておくだけでも、Reducer の分割・結合のための幅が増えるので、Reducer の分割・結合に悩んでいる方は API を把握するところから始めると良さそうかなと思っています。

多くの Feature で共有したい State は Dependency での管理を検討すること

TCA を利用してそこそこの規模のアプリを作っていると、いくつかの Feature で特定の State を共有したいという状況に遭遇することがあると思います。

そのような場合、実装の可能性として自分が見つけているものは以下の三通りです。

  1. State をバケツリレーする
  2. computed property を利用する
  3. SwiftUI の EnvironmentObject を利用する
  4. SharedStateClient のような Dependency を作成して利用する

まず 1 は最もシンプルな方法だと思います。
単純に必要な Feature に対してひたすら共有したい State をバケツリレーしていくだけです。
ただ、もちろんこの方法は手間がかかりますし、本来共有したくない Feature にもその State を共有しなければいけないですし、State の更新などにおける漏れも発生しやすいため、できれば避けるべきだと思います。

次に 2 の方法ですが、これは TCA の Examples の 01-GettingStarted-SharedState に書かれているものになります。
この方法は computed property を利用して、共有したい State を表現することで、State の更新漏れが起きない仕組みを実現しています。
しかし、この方法は computed property の定義コストが高かったり、共有したい状態がネストした Feature にまで影響するような場合は、かなり厳しいコードになってしまうと思います。

次に 3 の EnvironmentObject を利用する方法ですが、これも以下の二点から個人的には採用が厳しいかなと思っています。

  • EnvironmentObject の設定漏れでアプリが正しく動かなくなる可能性が高い
  • TCA の状態管理と EnvironmentObject での状態管理が二分化してしまい、コードの見通しが悪くなる

最後に 4 の方法は、TCA の Discussion で話されている内容となっています。

https://github.com/pointfreeco/swift-composable-architecture/discussions/1898#discussioncomment-4937380

Point-Free の中の方が提案しているものとなっており、複数の Feature 間で State を共有するのであれば、個人的にもこの方法が良さそうだと思っています。
具体的には、共有したい State を管理するための SharedStateClient のような Dependency を定義し、State を利用したい Feature で @Dependency(\.sharedStateClient) を利用するという方法となっています。

SharedStateClient の具体的な実装例として、isowords では以下のように PassthroughSubject を利用したものが導入されているようでした。

https://github.com/pointfreeco/isowords/pull/186

これについては、Point-Free の Slack community で話されていました。

また、多くの Discussion や上記の Slack などで「TCA における Shared State の管理方法」については議論されていて、例えば以下では Observation によって、何らかの新しい解決策が思い浮かぶかもしれないというような話も出ており、今後も色々模索されていきそうな雰囲気です。

https://github.com/pointfreeco/swift-composable-architecture/discussions/2320#discussioncomment-6541045

TCA において、State の管理にまつわるこの課題は大きなテーマなので、色々考えて探っていくのは面白そうです。

Store は補完が効く形・withDependencies を簡単に利用できる形で initialize すること

TCA で Store を initialize する際の書き方はいくつかありますが、個人的には以下のような形で書くことをお勧めしたいです。

let store = Store(
  initialState: SomeReducer.State(...)
) {
  SomeReducer()
}

まず上記の書き方でポイントとなるのは以下の二点です。

  1. initialState に指定する引数を .init(...) と書くのではなく、SomeReducer.State(...) と明示的に書いている
  2. SomeReducer を trailing closure を使って提供する

まず、1 についてですが、.init を使うとシンプルに書ける一方で、補完が効かなくなってしまいます。(仮に効くとしても、効かない場合が多いはず)
State の initialize では、何らかの値を渡しておきたいシーンが出てくると思いますが、そのような場合に元から SomeReducer.State と書いておいてくれると、コンパイラの力を借りやすくて助かります。

次に 2 についてですが、SomeReducer を trailing closure を使って提供しておくと、任意の Dependency を override したい時に withDependencies を使ってスッキリ書きやすくなります。

// このように書けば良い
let store = Store(
  initialState: SomeReducer.State(...)
) {
  SomeReducer()
} withDependencies: {
  $0.someDependency = ...
}

withDependencies を使わなくても Dependency を override する方法はいくつか存在しますが、個人的にはスッキリするのでこの方法を好んでいます。

テストサポート機能を理解して利用すること

TCA はテストサポート機能が充実しています。
TestStore を利用して、await store.send(...) したり await store.receive(...) したり、dependency を差し替えたりすることで簡単にテストが書けることは README にも記載されていて、わかりやすいテストサポート機能ですが、他にもいくつかのテストサポート機能を備えています。

ここで紹介したい他のテストサポート機能は以下の二つです。

  • exhaustivity
  • useMainSerialExecutor

exhaustivity については、少し古いですが以前に記事を書いているので、詳しくはこちらを参照してください。(確かその当時は存在していなかったため、記事には書かれていないですが、部分的に exhaustivity を off にしてテストできる withExhaustivity という機能も今は増えています)
https://zenn.dev/kalupas226/articles/eb8aaec9915bfe

useMainSerialExecutor は、v0.56.0 で TCA に導入されたテストサポート機能です。
本来 withTaskGroupEffect.merge を使って複数の並列処理を実行する場合、その内部で実行される処理の順序は不定になり、テストが難しい状態になりますが、useMainSerialExecutor を利用すると、テストにおいてはその実行順序を一定に保つことができ、テストが書けるようになります。

ある程度詳しい話は以下のブログなどに記載されています。

https://www.pointfree.co/blog/posts/110-reliably-testing-async-code-in-swift

useMainSerialExecutor に関する話は結構面白いので、記事にまとめたいなと思っているのですが、なかなか時間が割けていません...。どこかで書けたら、ここにも記事へのリンクを貼りたいと思います。

Previews と Unit Test をしっかり書くこと

TCA は Testing が強みであると度々主張されていますが、Unit Test をしっかり書くことで設計は綺麗になりやすいと思います。(TCA に限らずですが)
TCA を使っているのにテストしにくい構造になっているということは、かなりの確率で設計に問題があるはずだと思っています。
日頃から Unit Test を書いていればテストしにくい構造に気づきやすくなり、しかもいつの間にかテストも整っているという嬉しい状態になることができます。

そして、Xcode Previews が導入されて以降、Unit Test と同じくらい Previews のコードを書くことが、iOS アプリ開発においては重要なことだと個人的に感じています。(WWDC の何かのセッションでもそんなことが話されていました)
基本的にテストしやすい構造になっていれば Preview しやすいコードになっていると感じていますし、その逆もそうであると感じています。

幸い、TCA は Testing が強みであり、基本的には swift-dependencies を利用して依存関係を制御することから、Unit Test・Xcode Previews は活用しやすい状態になっていると思うので、どんどん書いていきましょう。

AlertState は extension を定義して Test などで使い回せるようにすること

TCA では swiftui-navigation で提供されている AlertState を利用することで簡単に Alert を表現できますが、AlertState への代入を直接行うのではなく、以下のように表現するのがおすすめです。

https://github.com/pointfreeco/swift-composable-architecture/blob/a384c00a2c9f2e1beadfb751044a812a77d6d2ec/Examples/SyncUps/SyncUps/SyncUpsList.swift#L158-L176

↑ のように定義することで、state.destination = .alert(.dataFailedToLoad) のように利用できるようになるため、テストなどで AlertState を assertion する時もそれを使い回せて便利です。

もちろん ConfirmationDialogState でも同じようなことができます。

変更履歴

記事の性質上、色々追記したり削除したりしそうなので、大きく変更した点があればここにタイムラインを追記しておこうと思います。

  • 2023/11/26
    • 「AlertState は extension を定義して Test などで使い回せるようにすること」を追加しました。
  • 2023/12/3
    • Effect.send は極力利用しないこと」を追加しました。
  • 2024/3/7
    • 「Delegate Action を活用すること」に ViewAction について追記しました。
    • 「Parent Reducer から Child Action を呼ばないこと」に reduce を呼ぶ方法について追記しました。
    • 「Reducer を分割・結合するための方法を理解して利用すること」のコードの最新化、SwitchStore についての記述の削除を行いました。
    • 「多くの Feature で共有したい State は Dependency での管理を検討すること」に注意文を追加しました。
    • 全体的にリンクが切れている部分を修正しました。

Discussion