[SwiftUI][TCA] Effect
概要
この記事ではTCA初心者の筆者が理解を深めていくために、
pointfreeco公式のサンプルアプリを基に理解しやすく整理していきます。
今回から2章として02Effectsの内容を整理していきます。
前回までの記事はこちら
今回扱うファイル
今回は公式サンプルの以下2つのファイルです。
Basics
Cancellation
Basics
Counter機能に加えてNumber factボタンでAPIをコールし、
そのレスポンスを処理し結果を表示しています。
1つポイントとしてdecrementボタン押下時の挙動で、
1秒後に数値がインクリメントされていることです。

State,Action
今回のBasicsでのここでのポイントは、
ActionのnumberFactResponseの引数でレスポンスを受け取るぐらいで、
これまでの記事とさほど変わりはないです。
struct EffectsBasicsState: Equatable {
var count = 0
var isNumberFactRequestInFlight = false
var numberFact: String?
}
enum EffectsBasicsAction: Equatable {
case decrementButtonTapped
case incrementButtonTapped
case numberFactButtonTapped
case numberFactResponse(Result<String, FactClient.Error>)
}
Environment
これまでの記事で使用されていなかったEnvironmentについてです。
定義されているfactとmainQueueを整理します。
struct EffectsBasicsEnvironment {
var fact: FactClient
var mainQueue: AnySchedulerOf<DispatchQueue>
}
FactClient
ますFactClientから見ていきます。
これはAPIコール用のClientで以下のように書かれています。
リクエストのパラメータに必要なIntを引数に、
レスポンスが入るEffectを返しています。
struct FactClient {
var fetch: (Int) -> Effect<String, Error>
struct Error: Swift.Error, Equatable {}
}
extension FactClient {
static let live = Self(
fetch: { number in
Effect.task {
do {
let (data, _) = try await URLSession.shared
.data(from: URL(string: "http://numbersapi.com/\(number)/trivia")!)
return String(decoding: data, as: UTF8.self)
} catch {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
return "\(number) is a good number Brent"
}
}
.setFailureType(to: Error.self)
.eraseToEffect()
}
)
}
AnySchedulerOf
続いてAnySchedulerOfです。
Schedulerを定義することでEffectで内で、
例えばdelayやreceive等で使用します。
Reducer
各アクションの中で今回Effectを使用している箇所を整理します。
以下はEffectに関する公式リファレンスです。
let effectsBasicsReducer = Reducer<
EffectsBasicsState, EffectsBasicsAction, EffectsBasicsEnvironment
> { state, action, environment in
switch action {
case .decrementButtonTapped:
state.count -= 1
state.numberFact = nil
// ①1秒後にカウントを再インクリメントするEffectを返す
return Effect(value: EffectsBasicsAction.incrementButtonTapped)
.delay(for: 1, scheduler: environment.mainQueue)
.eraseToEffect()
case .incrementButtonTapped:
state.count += 1
state.numberFact = nil
return .none
case .numberFactButtonTapped:
state.isNumberFactRequestInFlight = true
state.numberFact = nil
// ②APIからのレスポンスの値を`numberFactResponse`アクションに返すEffectを返す
return environment.fact.fetch(state.count)
.receive(on: environment.mainQueue)
.catchToEffect(EffectsBasicsAction.numberFactResponse)
// ③APIからのレスポンスをStateに更新する
case let .numberFactResponse(.success(response)):
state.isNumberFactRequestInFlight = false
state.numberFact = response
return .none
case .numberFactResponse(.failure):
state.isNumberFactRequestInFlight = false
return .none
}
}
①decrementButtonTapped
return Effect(value: EffectsBasicsAction.incrementButtonTapped)
.delay(for: 1, scheduler: environment.mainQueue)
.eraseToEffect()
まずは1秒後にカウントを再インクリメントするEffectである、
decrementButtonTappedアクションの部分です。
引数のvalueではアクションを定義し、
delayメソッドで1秒後にincrementButtonTappedアクションを送っています。
このままだとPublishersなので最後にeraseToEffectでEffectに変換しています。
②numberFactButtonTapped
return environment.fact.fetch(state.count)
.receive(on: environment.mainQueue)
.catchToEffect(EffectsBasicsAction.numberFactResponse)
ここはAPIからのレスポンスの値をnumberFactResponseアクションに返すEffectを返す、
numberFactButtonTappedアクションの部分です。
FactClientのfetchメソッドにてパラメータであるIntを受け取りAPIコールをします。
この段階ではEffectなのですが、
もちろんそれを処理するreceiveメソッドが必要になるので、
再びSchedulerのmainQueueでreceiveします。
最後にcatchToEffectで引数にActionを入れていますが、
これはActionが必要なのではなく、
今回入れているnumberFactResponseアクションの引数に、
Result<String, FactClient.Error> が定義されていることがポイントになります。
以下のcatchToEffectのソースを見てみると理解しやすいかと思います。
public func catchToEffect<T>(
_ transform: @escaping (Result<Output, Failure>) -> T
) -> Effect<T, Never> {
self
.map { transform(.success($0)) }
.catch { Just(transform(.failure($0))) }
.eraseToEffect()
}
なので簡潔に整理するとcatchToEffectのパラメータは、
APIのレスポンスのResultを持つActionを入れることで、
そのレスポンスの値を持つActionが走りStateに値を更新出来るようになります。※③の部分
View,Store
View側では特にポイントとなる新たな処理はありません。
Cancellation
上記のBasicsの機能に加えてCancellボタンで、
Environmentを中断することが出来ます。
(公式のままだとCancellボタン押す時間が無かったのでdelayを入れています)

State,Action
今回のCancellationでは、
ActionのcancelButtonTappedが加わったことです。
Action内容はReducerで記載します。
あと上記Basicsとの違いだとincrement・decrementが、
Stepperで実装されているのでstepperChangedのみになっています。
struct EffectsCancellationState: Equatable {
var count = 0
var currentTrivia: String?
var isTriviaRequestInFlight = false
}
enum EffectsCancellationAction: Equatable {
case cancelButtonTapped
case stepperChanged(Int)
case triviaButtonTapped
case triviaResponse(Result<String, FactClient.Error>)
}
Environment
Environmentは上記のBasicsと変更ありません。
Reducer
Effectで今回ポイントの.cancellableを使用している箇所を整理します。
.catchToEffectの後に.cancellableメソッドを定義することで、
Effectをキャンセル可能なものに変更します。
.cancel(id:)で使用され、
現在処理中のどのEffectがキャンセルされるべきかを識別します。
case .triviaButtonTapped:
state.currentTrivia = nil
state.isTriviaRequestInFlight = true
return environment.fact.fetch(state.count)
.receive(on: environment.mainQueue)
.catchToEffect(EffectsCancellationAction.triviaResponse)
.cancellable(id: TriviaRequestId())
そして上記Cancellableがコールされる契機である、.cancelは、
与えられた識別子で現在処理中のEffectをキャンセルするEffectを返します。
識別子は、文字列のようなハッシュ可能な値なら何でも使えますが、
Hashableに準拠した新しい型の構造体を定義することで、
タイプミスによるミスを少し抑えることができます。
case .cancelButtonTapped:
state.isTriviaRequestInFlight = false
return .cancel(id: TriviaRequestId())
case let .stepperChanged(value):
state.count = value
state.currentTrivia = nil
state.isTriviaRequestInFlight = false
return .cancel(id: TriviaRequestId())
View,Store
こちらもView側では特にポイントとなる新たな処理はありません。
Discussion