🚫

TCA で Parent Reducer から Child Action を呼んではいけない

2023/05/07に公開
2

TCA は Reducer を細かく分割することができます。Reducer を適切な粒度で分割することで、様々なメリットを享受できます。(本質ではないため、本記事では割愛します)
また、分割された Recucer 同士は、基本的にはコミュニケーションしやすい状態になっています。
例えば、親画面のドメインを管理する Parent Reducer と子画面のドメインを管理する Child Reducer があった場合、Child Reducer で起こった Action を Parent Reducer で処理することは非常に簡単に実現できます。

また、Parent Reducer から Child Action を呼ぶことも可能となっています。
自分自身 TCA を使って Reducer を分割する中で、Parent Reducer から Child Action を呼んでしまうコードはいくつか書いてきました。
例えば、Parent Reducer で起きた処理に応じて Child Reducer の refresh などという Action を呼んで、Child Reducer で refresh のための API 通信を行いたい時などがありました。

この 「Parent Reducer から Child Action を呼ぶ」 という行為について、自分自身あまり良くないコードだなと思いつつも、しっかり言語化しないままやってきてしまったのですが、つい最近このことに関する Discussion を発見することができました。

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

個人的にこの Discussion の内容で話されていることは、TCA を利用する上で非常に重要なことだと感じたため、その内容をまとめてみようと思います。

Discussion で話されている内容について

どんな問題についての Discussion なのか

Discussion は、「Parent Reducer から Child Action を呼ぶ」コードの例から始まっています。
そのコードは以下のようなものです。

struct Parent: ReducerProtocol {
    struct State: Equatable {
        var child: Child.State
    }

    enum Action: Equatable {
        case child(Child.Action)
    }

    var body: some ReducerProtocol<State,Action> {
        Scope(state: \.child, action: /Action.child) {
            Child()
        }
        Reduce { state, action in
            return .none
        }
    }
}

struct Child: ReducerProtocol {
    struct State: Equatable {
        var text: String
    }
    enum Action: Equatable {
        case update
    }

    var body: some ReducerProtocol<State,Action> {
        Reduce { state, action in
            switch action {
                case .update:
                state.text = // do validation or api calls etc. that is related to the Childs Domain
                return .none
            }
        }
    }
}

struct ParentView: View {
    let store: StoreOf<Parent>

    var body: some View {
        WithViewStore(self.store) { viewStore in
            Button(action: {
                viewStore.send(.child(.update))
                // or..
                viewStore.send(.didPressButton) // in the reducer return Effect(value: .child(.update))
            }, label: {
                Text("Trigger Child Action")
            })
        }
    }
}

上記のコードは、

  • Child Action が update という case を持っている
  • Parent Reducer もしくは親の ViewStore 経由で Child Action の update が呼ばれうる

という状況を示しています。

これに対して TCA のメンテナーである Brandon さんをはじめとして、何人かの方々が議論しています。

Brandon さんの見解は以下のようなものです。

  • TCA では「Parent Reducer から Child Action を呼ぶこと」も「Parent View から利用できる ViewStore 経由で Child Action を呼ぶこと」のどちらも推奨されていない
  • TCA における Action は、「Reducer の中で何をしたいか」ではなく「(主に) ユーザーが UI 上で何をしたか」にちなんで名付けられるべきである
    • 例えば didPressButton は意味が明確で簡潔である
    • 例に出されている update は不透明で、Child domain の複雑さを知らない人からすると、その Action がいつ send されるものなのかが分かりにくいものとなってしまっている
  • TCA における Action はメソッドのようなものだと考えるべきではないし、Action を send することはメソッドの呼び出しよりもコストが高いものであることを認識しなければならない
    • これについては Performance にも示されている

Brandon さんはこの見解に加え、「Parent Reducer から Child Action を呼ぶこと」で起きる上記のような問題を解決するための方法も提案されています。
そちらも見てみることにします。

問題の解決方法

解決策として提案されているものは以下のようなものです。

  1. Child State に EffectTask を返す mutating function を生やす
  2. Parent Reducer からは Child State 経由でその function を呼び出しつつ、fucntion によって返される EffectTask を Child Action に map する

コードで表すと、以下のような形です。

// 1
extension Child.State {
  mutating func update() -> Effect<Child.Action> {}
}

// 2
case .buttonTapped:
  return state.child.update().map(Action.child)

この解決策について Brandon さんは以下のように述べています。

  • この解決策で追加されている update は Action ではなくメソッドであるため、「ユーザーが何をしたのか」よりも「実際に何を行うのか」が命名に表されていることが適切であり、Action の時には不適切であった update という命名を利用しても良い
  • update は単純なメソッドであるため、Parent domain でも Child domain でもどこからでも簡単に呼び出すことができる
  • このスタイルは Child State にアクセスできる場所ならどこでも child.update() を呼び出すことができるため、柔軟性が高い
  • また、単純なメソッドを呼び出しているだけで、システムに新しい Action を送信することはない構造となっているため、パフォーマンス的なメリットもある

Discussion の中で追加で話されていますが、上記のような mutating function は Reducer からのみ呼ばれることが基本的に保証されているため、以下のようにすれば Dependency を利用することもできます。

extension Child.State {
  mutating func update() -> Effect<Child.Action> {
    @Dependency(\.apiClient) var apiClient
    …
  }
}

Discussion を読んでみての感想

「Parent Reducer が Child Action を呼ぶ」ことについて、この Discussion を読む前に自分が思っていたデメリットには以下のようなものがありました。

  • Parent Reducer が Child Reducer の詳細を知らなければいけない設計になってしまう
    • コードの見通しが悪くなる
    • モジュールの独立性も損なわれる
    • テストも複雑になる

Discussion を読んだことで、上記に加えて以下のようなデメリットがあることもわかりました。

  • Action の名前が命令的になってしまう
    • 例えば update など
    • update などは buttonTapped などの Action と比較して、いつ呼ばれる Action なのか自明ではないため、コードの複雑度を増加させてしまう
  • 余計な Action が増えてしまいパフォーマンス的にも良くない

この解決策として「Child State に EffectTask を返す mutating function を返す」というものが提案されていましたが、最初見た時は「この発想はなかったな...」という感想を持ちました。

ただ、Discussion で提案されている方法を使ったとしても、Parent Reducer から Child Reducer の処理を呼び起こしてしまうと、どうしてもコードの複雑度は高まってしまうため、やはりできるだけ Parent Reducer は Child Reducer を意識しなくても良い形になるように設計できると良さそうかなと同時に感じました。

今回色々と学ぶことができたので、今後はやむなく Parent Reducer から Child Reducer の処理を呼び起こしたいようなタイミングでは、Discussion で提案されている方法を使っていこうと思いました。

Discussion

yimajoyimajo

Discussionのご紹介ありがとうございます。知らかなったので勉強になります ☺️

気になる点として、この記事の密結合という言葉の定義です。本来密結合とは、一方の変更が他方に影響を及ぼす状況が相互で起きている依存の状態を指すと思います。つまり相互依存が前提ではないでしょうか?この例ではParrentはChildに依存していますが、ChildはParentに依存していないので、密結合していないと思いました。

アイカワアイカワ

imajo さんコメントありがとうございます!

本来密結合とは、一方の変更が他方に影響を及ぼす状況が相互で起きている依存の状態を指すと思います。つまり相互依存が前提ではないでしょうか?この例ではParrentはChildに依存していますが、ChildはParentに依存していないので、密結合していないと思いました。

確かに密結合や疎結合という言葉を使ってしまうと語弊があったかもしれないですね...
今回の場合「Parent 側が Child domain の詳細を知る必要が出てきてしまうこと」が伝えたいことなので、そのように書き換えようと思います🙏