TCAでStateに関する処理をどう書くか
はじめに
TCAでStateに関する処理はどう書くかについて調べた。
なぜ調べる必要があるのか
- Reducer内部にロジックを書きたくない気がしてしまうことがある
- そこそこ複雑な処理をReducerに書きたくない気分
- Stateに関する処理をEffectを使ってしまうのはやりすぎなので避けたい
- EffectはWeb API利用や永続化されたデータの取り扱いなどに利用する
- シングルトンを使ってReducer内部やSwiftUI.Viewで処理を行うのは妥当でない場合がある
- なぜ妥当ではないのかはまた今度書く
この文章の結論
Reducer内部でStateに関する処理はそのまま関数化せずとも書いていいと思う。テストコードを書いてるだろうから、そこでテストできるため。しかし処理のまとまりとして一つの何かにまとめたいという気分になるなら、次のことを検討してみる。
- privateな関数をReducerと同じファイルに用意する(Private Helper)
- もし他のReducerと使い回すことになったらそのとき考える
- Reducerそのものを使いまわせる場合
- Reducerを使い回せばいいだけ
- Reducerそのものを使いまわせる場合
- Reducerを使い回すようなことではない場合
- グローバルな関数を検討する
- もし他のReducerと使い回すことになったらそのとき考える
- SwiftUI.ViewがStateに対して変化して欲しいならStateのコンピューティッドプロパティとする
- Stateの関数とすることもできる
- 複雑な気もするので最後の手段かも
privateな関数をReducerと同じファイルに書く(Private Helper)
TCAのサンプルコードから、privateな関数として特定のReducerが扱えるようにしている例がある。
/// 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ファイルに書いているため。
同じように、SwiftUI.Viewに対してprivateな関数をとりあえず1ファイルにしているサンプルもある。使ってる箇所が1箇所ならまずはこれで十分だろう。
コンピューティッドプロパティとして処理を実装する
SwiftUI.Viewに監視させて変化をViewに伝えるならばコンピューティッドプロパティにしてしまうほうがいい。サンプルコードでは次の箇所が該当する。
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に監視させるべきかどうかはともかく、コンピューティッドプロパティで計算すればよいことを知ってるのは役に立つはず。
isowordsでのStateのコンピューティッドプロパティ利用例
少し意図は変わるが、isowordsではStateからネストされたStateを返すコンピューティッドプロパティで次のようにget/setを使っていたりもする。
Stateの関数として書くこともできる
やるかどうかは別として、Stateの関数にはできる。ただこれは引数が必要になる場合の最後の手段かも(他の方法で充分わかりやすいため)。
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
}
}
}
ReducerのExtensionとして実装する(おすすめしない)
Reducerをwhereで条件を指定しextensionで機能拡張することでそのReducerしかないメソッドを生やすことができる。
isowordsでは例えば次のような感じ。
extension Reducer where State == AppState, Action == AppAction, Environment == AppEnvironment {
func persistence() -> Self {
self
...
省略
ただ、このようなパターンはSelfを返すようなメソッドを作成していて、Reducer作成時にチェーンで機能を拡張することを目的としている場合のみ使われている。
なのでReducer内のStateを変更する処理のために行うとは違う。