Open6

TCAでStateに関する処理をどう書くか

yimajoyimajo

はじめに

TCAでStateに関する処理はどう書くかについて調べた。

なぜ調べる必要があるのか

  • Reducer内部にロジックを書きたくない気がしてしまうことがある
    • そこそこ複雑な処理をReducerに書きたくない気分
  • Stateに関する処理をEffectを使ってしまうのはやりすぎなので避けたい
    • EffectはWeb API利用や永続化されたデータの取り扱いなどに利用する
  • シングルトンを使ってReducer内部やSwiftUI.Viewで処理を行うのは妥当でない場合がある
    • なぜ妥当ではないのかはまた今度書く

この文章の結論

Reducer内部でStateに関する処理はそのまま関数化せずとも書いていいと思う。テストコードを書いてるだろうから、そこでテストできるため。しかし処理のまとまりとして一つの何かにまとめたいという気分になるなら、次のことを検討してみる。

  • privateな関数をReducerと同じファイルに用意する(Private Helper)
    • もし他のReducerと使い回すことになったらそのとき考える
      • Reducerそのものを使いまわせる場合
        • Reducerを使い回せばいいだけ
    • Reducerを使い回すようなことではない場合
      • グローバルな関数を検討する
  • SwiftUI.ViewがStateに対して変化して欲しいならStateのコンピューティッドプロパティとする
  • Stateの関数とすることもできる
    • 複雑な気もするので最後の手段かも
yimajoyimajo

privateな関数をReducerと同じファイルに書く(Private Helper)

TCAのサンプルコードから、privateな関数として特定のReducerが扱えるようにしている例がある。

https://github.com/pointfreeco/swift-composable-architecture/blob/6f1bc259e41ac7bd0a8f18b25e9de9d2eb58861a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift#L263

/// Checks if a number is prime or not.
private func isPrime(_ p: Int) -> Bool {
  if p <= 1 { return false }
  if p <= 3 { return true }
  for i in 2...Int(sqrtf(Float(p))) {
    if p % i == 0 { return false }
  }
  return true
}

let sharedStateCounterReducer = Reducer<
  SharedState.CounterState, SharedStateAction.CounterAction, Void
> { state, action, _ in
  switch action {
  case .alertDismissed:
    state.alert = nil
    return .none

  case .decrementButtonTapped:
    state.count -= 1
    state.numberOfCounts += 1
    state.minCount = min(state.minCount, state.count)
    return .none

  case .incrementButtonTapped:
    state.count += 1
    state.numberOfCounts += 1
    state.maxCount = max(state.maxCount, state.count)
    return .none

  case .isPrimeButtonTapped:
    state.alert = .init(
      title: isPrime(state.count)
        ? .init("👍 The number \(state.count) is prime!")
        : .init("👎 The number \(state.count) is not prime :(")
    )
    return .none
  }
}

しかしこのやり方はStateだからとかではなくサンプルコードだからこういうやり方がシンプルでいいという意図もありそう。

なぜなら、Effectも同じように1ファイルに書いているため。

https://github.com/pointfreeco/swift-composable-architecture/blob/6f1bc259e41ac7bd0a8f18b25e9de9d2eb58861a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift#L212

同じように、SwiftUI.Viewに対してprivateな関数をとりあえず1ファイルにしているサンプルもある。使ってる箇所が1箇所ならまずはこれで十分だろう。

https://github.com/pointfreeco/swift-composable-architecture/blob/6f1bc259e41ac7bd0a8f18b25e9de9d2eb58861a/Examples/Search/Search/SearchView.swift#L167

yimajoyimajo

コンピューティッドプロパティとして処理を実装する

SwiftUI.Viewに監視させて変化をViewに伝えるならばコンピューティッドプロパティにしてしまうほうがいい。サンプルコードでは次の箇所が該当する。

https://github.com/pointfreeco/swift-composable-architecture/blob/2bd9b7568e0ef7c262b5985d1d0253d659db064c/Examples/Todos/Todos/Todos.swift#L15-L20

Stateの1つのプロパティに対してもう一つのプロパティのフィルタリングを行っているので合理的に思えるし、計算量が少なく関数にする必要もない(関数を使う例もあった気もするが見つからない)。

struct AppState: Equatable {
  var editMode: EditMode = .inactive
  var filter: Filter = .all
  var todos: IdentifiedArrayOf<Todo> = []

  var filteredTodos: IdentifiedArrayOf<Todo> {
    switch filter {
    case .active: return self.todos.filter { !$0.isComplete }
    case .all: return self.todos
    case .completed: return self.todos.filter { $0.isComplete }
    }
  }
}

利用箇所は次のようなSwiftUI.View中で利用している


struct AppView: View {
  struct ViewState: Equatable {
    var editMode: EditMode
    var isClearCompletedButtonDisabled: Bool
  }

  let store: Store<AppState, AppAction>

  var body: some View {
    WithViewStore(self.store.scope(state: { $0.view })) { viewStore in
      NavigationView {
        VStack(alignment: .leading) {
          WithViewStore(self.store.scope(state: { $0.filter }, action: AppAction.filterPicked)) {
            filterViewStore in
            Picker(
              "Filter", selection: filterViewStore.binding(send: { $0 }).animation()
            ) {
              ForEach(Filter.allCases, id: \.self) { filter in
                Text(filter.rawValue).tag(filter)
              }
            }
            .pickerStyle(SegmentedPickerStyle())
          }
          .padding([.leading, .trailing])

          List {
            ForEachStore(
              self.store.scope(state: { $0.filteredTodos }, action: AppAction.todo(id:action:)),
              content: TodoView.init(store:)
            )

必ずしもSwiftUI.Viewに監視させるべきかどうかはともかく、コンピューティッドプロパティで計算すればよいことを知ってるのは役に立つはず。

yimajoyimajo

Stateの関数として書くこともできる

やるかどうかは別として、Stateの関数にはできる。ただこれは引数が必要になる場合の最後の手段かも(他の方法で充分わかりやすいため)。

https://github.com/pointfreeco/isowords/blob/fcff52ccf176f40db55951d5897def7cd9b879cf/Sources/GameCore/SoundsCore.swift#L155

extension GameState {
  func hasBeenPlayed(word: String) -> Bool {
    self.moves.contains {
      guard case let .playedWord(faces) = $0.type else { return false }
      return self.cubes.string(from: faces) == word
    }
  }
}
yimajoyimajo

ReducerのExtensionとして実装する(おすすめしない)

Reducerをwhereで条件を指定しextensionで機能拡張することでそのReducerしかないメソッドを生やすことができる。

isowordsでは例えば次のような感じ。

https://github.com/pointfreeco/isowords/blob/244925184babddd477d637bdc216fb34d1d8f88d/Sources/AppFeature/AppView.swift#L189

extension Reducer where State == AppState, Action == AppAction, Environment == AppEnvironment {
  func persistence() -> Self {
    self
...
省略

ただ、このようなパターンはSelfを返すようなメソッドを作成していて、Reducer作成時にチェーンで機能を拡張することを目的としている場合のみ使われている。

https://github.com/pointfreeco/isowords/blob/244925184babddd477d637bdc216fb34d1d8f88d/Sources/AppFeature/AppView.swift#L187

なのでReducer内のStateを変更する処理のために行うとは違う。