🚧

TCA で Reducer を combine する順序は意識しなければいけない

2022/08/21に公開

TCA は画面や Component ごとに Reducer を分割していくことができ、分割した Reducer を結合することによって tree 状の State を容易に構築することができます。

モジュールとしての独立性を高めるために、これが容易にできることはありがたいことですが、「分割した Reducer を結合する」際に気をつけなければいけないことがあります。

それは Reducer を combine する順序です。
Reducer は以下のように combine することができます。(この記事では combine や pullback についての詳しい説明はしません)

let parentReducer = Reducer<ParentState, ParentAction, ParentEnvironment>.combine(
  childReducer.optional().pullback(
    state: \.child,
    action: /ParentAction.child,
    environment: { $0.child }
  ),
  Reducer { state, action, environment in
    switch action
    case .child(.dismiss):
      state.child = nil
      return .none
    ...
    }
  },
)

これは正しい例ですが、以下のように combine することは実は誤っています。

 let parentReducer = Reducer<ParentState, ParentAction, ParentEnvironment>.combine(
   Reducer { state, action, environment in
     switch action
     case .child(.dismiss):
       state.child = nil
       return .none
     ...
     }
   },
   childReducer.optional().pullback(
     state: \.child,
     action: /ParentAction.child,
     environment: { $0.child }
   )
 )

最初の例と上記で異なるのは Reducer を combine している順序です。
それぞれ以下のように combine しています。

  • 最初の例
    • childReducer を先に書いてから、Reducer を combine している
  • 2 番目の例
    • Reducer を先に書いてから、childReducer を cobmine している

2 番目の例は正しいですが、最初の例のように Reducer を combine してしまうと意図しないバグを起こす可能性があります。

実はその話はしっかりと TCA の Reducer.swift の combine などの Documentation comment に記載されています。

  /// It is important to note that the order of combining reducers matter. Combining `reducerA` with
  /// `reducerB` is not necessarily the same as combining `reducerB` with `reducerA`.

「Reducer の組み合わせの順序は重要。reducerAreducerB を組み合わせることは、reducerBreducerA を組み合わせることと同じとは限らない」ということが書かれています。

combine の順序を誤るとどういった場合に意図しないバグを引き起こすかについても、同じく comment に記載されています。

例えば以下のような例を考えてみます。

let parentReducer = Reducer<ParentState, ParentAction, ParentEnvironment>.combine(
  childReducer.optional().pullback(
    state: \.child,
    action: /ParentAction.child,
    environment: { $0.child }
  ),
  Reducer { state, action, environment in
    switch action
    case .child(.dismiss):
      state.child = nil
      return .none
    ...
    }
  },
)

このように combine した場合、基本的に childReducer の処理が先に実行され、Reducer の処理は後に実行されます。

では、combine する順序を逆にするとどうなるでしょうか。

let parentReducer = Reducer<ParentState, ParentAction, ParentEnvironment>.combine(
  Reducer { state, action, environment in
    switch action
    case .child(.dismiss):
      state.child = nil
      return .none
    ...
    }
  },
  childReducer.optional().pullback(
    state: \.child,
    action: /ParentAction.child,
    environment: { $0.child }
  )
)

こうしてしまうと Reducer で child state を nil にしてしまった後で childReducer の処理が走るという構造になってしまい、child state が nil になっているため childReducer は自分の action に反応することができなくなってしまいます。

そのため、基本的に Reducer の combine は「child reducer を先に結合しなければならない」と考えておくと良いと思います。

参考

Discussion