TCAFeatureAction で Action を見やすく・安全にしよう

2023/05/08に公開

TCA の Action を定義する際に起きる問題

TCA の Action は雑に定義していると、以下のように case が羅列されてしまい、すぐに見通しが悪くなってしまいます。

struct Example: ReducerProtocol {
    struct State: Equatable { ... }

    enum Action {
        case incrementButtonTapped
        case decrementButtonTapped
        case ...
        case ...
        case ...
        // ...
    }

    var body: some ReducerProtocolOf<Self> {
        ...
    }
}

また Action には様々なものがあり、例えばどのようなタイミングで呼ばれるものなのかは Action ごとに異なってきます。

例えば、以下のような Action があったとします。

enum Action {
    case incrementButtonTapped
    case exampleResponse(TaskResult<Response>)
    case userLoggedIn
}

上記の Action はそれぞれ以下のようなタイミングで呼ばれます。

  • incrementButtonTapped
    • View 内の incrementButton がユーザーにタップされた時
  • exampleResponse(TaskResult<Response>)
    • Reducer 内で、ある API Request の Response が返却された時
  • userLoggedIn
    • 自身の Reducer でログイン処理が終わったタイミングで Parent Reducer に処理を委譲する時

このように Action は様々なタイミングで呼ばれる可能性がありますが、それらが同じ階層の case として定義されているとコードが見辛くなってしまったり、userLoggedIn などの Parent Reducer で処理して欲しい Action を誤って自身の Reducer で処理してしまう可能性も出てきてしまいます。

問題の解決方法

実は、このような問題を解決するための手段が Krzysztof さんによって提案されています。

その手段は TCA Action Boundaries で説明されており、その記事をもとに Thoughts on "Action Boundaries" to keep Actions organized and their intent explicit という Discussion でも議論が行われています。

https://www.merowing.info/boundries-in-tca/
https://github.com/pointfreeco/swift-composable-architecture/discussions/1440

本記事では上に紹介した記事などで提案されている解決方法について、掻い摘んで説明しようと思います。

大まかな Action の種類

早速解決方法について説明する前に、TCA を利用する際に定義されうる大まかな Action の種類を確認しておきます。

実は先ほどその種類が少し登場していました。そのコードをもう一度示します。

enum Action {
    case incrementButtonTapped
    case exampleResponse(TaskResult<Response>)
    case userLoggedIn
}

この Action で定義されているそれぞれの case が呼ばれるタイミングは以下のようなものでした。

  • incrementButtonTapped
    • View 内の incrementButton がユーザーにタップされた時
  • exampleResponse(TaskResult<Response>)
    • Reducer 内 で、ある API Request の Response が返却された時
  • userLoggedIn
    • 自身の Reducer でログイン処理が終わったタイミングで Parent Reducer に 処理を委譲する

実は TCA で定義される Action は大まかに上の 3 つに分けることができます。
もう少し分類をわかりやすく書いておくと以下のようになります。

  • View で発生する Action
    • ユーザーがボタンをタップした、onAppear などのイベント
  • Reducer 内部で発生する Action
    • Reducer 内で Effect が実行された時に Reducer 内で発生するもの
  • Parent Reducer に処理を委譲するための Action
    • どうしても自身の Reducer だけでは処理を実行しきれない場合、Parent Reducer に処理を委譲して処理してもらいたくなる場合がある

他に考えられる Action はいくつかあると思いますが、ここで説明すると説明が複雑になってしまうため後述します。

Action を見やすく・安全にするための方法

前提について確認し終わったので、Action を見やすく・安全にするための実際の方法も見ていきます。

その方法は先ほど分類した 3 つの Action の種類に従って Action enum を定義できるようにするというものです。
具体的な方法についても見ていきます。

何度か登場していますが、以下の Action を例にその方法を適用してみようと思います。

enum Action {
    case incrementButtonTapped
    case exampleResponse(TaskResult<Response>)
    case userLoggedIn
}

この Action を以下のように記述するようにします。

enum Action {
    case view(ViewAction)
    case `internal`(InternalAction)
    case delegate(DelegateAction)

    enum ViewAction: Equatable {
        case incrementButtonTapped
    }

    enum InternalAction: Equatable {
        case exampleResponse(TaskResult<Response>)
    }

    enum DelegateAction: Equatable {
        case userLoggedIn
    }
}

シンプルな解決策ではありますが、ViewAction / InternalAction / DelegateAction という 3 つの enum を内部に用意したことによって、Action の case が 3 つしか存在しなくなり、見通しが良くなりました。
このおかげで Reducer の実装も以下のようになり、見通しが良くなります。

var body: some ReducerProtocolOf<Self> {
    Reduce { state, action in
        switch action {
        case let .view(viewAction):
            switch viewAction {
                // ...
            }

        case let .internal(internalAction):
            switch internalAction {
                // ...
            }

        // DelegateAction は自身では処理しない
        case .delegate:
            return .none
        }
    }
}

この ViewAction / InternalAction / DelegateAction ですが、全実装者にある程度強制するために、以下のような protocol を用意することも提案されています。

protocol TCAFeatureAction {
    associatedtype ViewAction
    associatedtype InternalAction
    associatedtype DelegateAction

    static func view(_: ViewAction) -> Self
    static func `internal`(_: InternalAction) -> Self
    static func delegate(_: DelegateAction) -> Self
}

Action をこの protocol に準拠させることを強制すれば、ViewAction / InternalAction / DelegateAction の定義を開発者に強制できます。

強制ではなくても、以下のようにして半強制くらいにしても良いかもしれません。

protocol TCAFeatureAction {
    associatedtype ViewAction = Never
    associatedtype InternalAction = Never
    associatedtype DelegateAction = Never

    static func view(_: ViewAction) -> Self
    static func `internal`(_: InternalAction) -> Self
    static func delegate(_: DelegateAction) -> Self
}

extension TCAFeatureAction where ViewAction == Never {
    static func view(_: ViewAction) -> Self {}
}

extension TCAFeatureAction where InternalAction == Never {
    static func view(_: InternalAction) -> Self {}
}

extension TCAFeatureAction where DelegateAction == Never {
    static func view(_: DelegateAction) -> Self {}
}

どのようにするかはプロジェクトごとに決めれば良いと思いますが、このように ViewAction / InternalAction / DelegateAction という形で enum を定義するようにすれば、Action の見通しは良くなりますし、呼ばれたくないタイミングで何らかの Action が呼ばれてしまうことを防ぐこともできます。

いくつかの補足

ここまでで TCA の Action を見やすく・安全にする方法については説明し終わったのですが、以下 2 点を追加で補足しておこうと思います。

  1. 他の Action はどうするのか
  2. もう少し便利に TCAFeatureAction を利用するための方法

ここからは記事や Discussion に記載されている話ではなく、自分の見解が中心となってしまうので参考程度にしていただけたら嬉しいです🙏

1. 他の Action はどうするのか

一つ思い浮かぶかもしれない疑問として、「ViewAction / InternalAction / DelegateAction 以外の Action はどうするのか?」というものがあるかもしれません。
自分が TCA を使って開発していて、上記以外に分類されうるものとして考えられるものは以下のようなものがあるかなと思っています。

  • 外部 Reducer から実行される Action
  • Child 系 Action

まず、「外部 Reducer から実行される Action」ですが、こちらについては例えば Parent Reducer から Child Action を呼び出したいというタイミングがあると思います。
これに関して、自分はそのような Action が定義されるべきではないと思っているため、考える必要がないと思っています。
詳しくは TCA で Parent Reducer から Child Action を呼んではいけない で説明しているので、そちらをご参照ください。

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

次の「Child 系 Action」についてですが、こちらは例えば以下のようなものがあると思います。

  1. Parent View をいくつかの Component に分割した時の Component 的な Action
  2. Parent View から遷移する画面の Action

1 についてですが、ViewAction / InternalAction / DelegateAction の 3 つに分類しておけば、Action が以下のような見た目になるため、わざわざ分類を増やす必要がないと考えています。

enum Action {
    case view(ViewAction)
    case `internal`(InternalAction)
    case delegate(DelegateAction)

    case componentA(ComponentA.Action)
    case componentB(ComponentB.Action)
}

個人的には上記のような形でも十分見やすいと感じています。
また、実は Discussion の中ではこれらの Child Action のようなものを InternalAction 内に定義することも議論されていたりはするのですが、仮に Child Action を InternalAction 内に定義してしまうと、ScopeForEachStore などの API の利用方法が煩雑になってしまうため、それも理由で自分は 3 つの分類外にしてしまっても良いかなと思っています。

2 の「Parent View から遷移する画面の Action」についてですが、こちらは現在 navigation-beta で開発が進んでいる TCA の navigation support によって、以下のように定義されると考えているため、例えば destination という case が増える形になると思っています。

enum Action {
    case view(ViewAction)
    case `internal`(InternalAction)
    case delegate(DelegateAction)
    case destination(PresentationAction<Destination.Action>)

    case componentA(ComponentA.Action)
    case componentB(ComponentB.Action)
}

// ...

struct Destination: ReducerProtocol {
    enum State: Equatable { ... }

    enum Action { ... }

    var body: some ReducerProtocolOf<Self> {
        ...
    }
}

navigation-beta についてはこの記事では詳しく解説しませんが、以下 2 つあたりの Discussion を見ていただければ大体わかると思います。

https://github.com/pointfreeco/swift-composable-architecture/discussions/1944
https://github.com/pointfreeco/swift-composable-architecture/discussions/2048

もしやる気があれば、navigation-beta についての記事も書くかもしれません。(多分、navigation-beta が main branch にマージされる頃には、しっかりしたドキュメントが追加されるので書かない気はします...🙇‍♂️)

2. もう少し便利に TCAFeatureAction を利用するための方法

Krzysztof さんの追加の記事である TCA Action Boundaries - Convenience や、Discussion の中では TCAFeatureAction をより便利に利用するための方法についても話されているようです。

https://www.merowing.info/tca-action-boundries-convenience/

上の記事では例えば、ViewAction が Reducer の中で呼ばれようとしたらコンパイルエラーにしたり、SwiftUI View 内では ViewAction を簡単に呼べるようにしたりする方法などが紹介されているようです。 (自分は記事についてはまだ見ていないので、詳細はわかりません)

実際、そのまま TCAFeatureAction を導入するとなると TCA が謳っている Ergonomics の一部分が失われてしまうことにはなるので、色々ヘルパーを書いて良い感じに扱えるようにすると良さそうかもしれません。

感想

個人的に TCAFeatureAction は TCA をチームで利用する時などに有効活用できる手段だと感じています。
Brandon さんも General tips and tricks の中で推奨している姿勢 であることを見ると、採用しても問題ないかなと思います。

もちろんチームによって事情は異なると思うので、チームの中で再考した上で、より良い形で採用できると良いのかなと思いました。

Discussion