TCA を初心者にわかりやすく解説してみる試み
最近、TCA(The Composable Architecture)なるものを勉強しました。
勉強する上で、ドキュメントの数が少ないなーと思ったので、完全初心者にもわかるように解説してみる試みです。
TCA のイメージをなんとな〜く理解する
TCA フレームワークでは、「View」と「Reducer」に分かれています。
View は見た目を表示するだけ。
Reducer は状態管理(State)とアクション(Action)の処理を担当します。
View は、Reducer にある State を使って画面を作ったり、その画面でのイベント(ボタンが押された!など)を Reducer に伝えます。(処理は一切書きません)
Reducer は、View 側から受け取ったアクションの処理を実行し、その時に State を書き換えたりします。
View に処理を書かず、画面を作ることだけに専念できるのが個人的に好みです。
基本的に、画面 1 つに対して 1 つの Reducer を作るイメージです。
メリットとかは私もまだ把握しきれていないので、わかりやすそうな記事を貼っておきます。
https://qiita.com/karamage/items/f63a5750e65c5c9745ae
TCA 導入の仕方
XCode -> File -> Add Package Dependencies に、以下を打ち込みます
https://github.com/pointfreeco/swift-composable-architecture
Reducer の定義方法
Reducer は、以下のように定義します。
ContentView.swift// 必須!!!!
import ComposableArchitecture
@Reducer
struct ContentFeature {
@ObservableState
struct State {
var sampleState: String = "hogehoge"
}
enum Action {
case sampleAction
case sampleAction2(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .sampleAction:
print("Hello, world!")
return .none
case let .sampleAction2(text):
state.sampleState = text
return .none
}
}
}
}
Reducer の名前は、最後に「Feature」と付けるのが慣習(?)らしいです。 本来は、View と Reducer は分けた方がいいのですが、今回は見やすさのために、同じファイルに書いていきます。
普段は@State のように宣言する状態変数を、State という構造体の中に書きます。
ObservableState マクロを忘れずに!!
ContentView.swift// 状態変数を宣言
@ObservableState
struct State {
var sampleState: String = "hogehoge"
}
View 内で発生するイベント名は、Action の enum の中に case として記述します。
そして、イベントの処理内容は、body の中に記述します。
enum では名前だけ。処理は body です。
ContentView.swift// アクション名を定義
enum Action {
case sampleAction
}
// アクションの処理内容を定義
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .sampleAction:
print("Hello, world!")
return .none
}
}
}
「return .none」の部分は、次のセクションで説明するので、「こう書くんだな」程度に捉えておいてください。
もし、Action で引数を受け取りたい場合は、Action の enum に型だけを書きます。
引数名は、body 内の switch にて「case let アクション名(引数名)」と記述して決めます。
ContentView.swift// String型を受け取るように定義する
enum Action {
case sampleAction2(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
// ここで引数名を決める
case let .sampleAction2(text):
state.sampleState = text
return .none
}
}
}
「case .sampleAction2(let text)」でも大丈夫です。
Reducer と View を繋ぐ方法
Reducer の宣言方法は軽く理解できたので、作った Reducer を View と紐づけてみましょう。
まずは、紐づけたい View 側に以下を追記します
ContentView.swiftstruct ContentView: View {
// 追記!!!
let store: StoreOf<ContentFeature>
var body: some View {
// ...
}
}
これで「store.sampleState」というように、Reducer 側の状態変数やアクションを呼び出せるようになりました。
しかし、store の初期値を指定していないため、このままだと呼び出し場所でエラーになってしまいます。(App ファイルとか Preview とか)
なので、store の値を以下のように指定します。
ContentView.swiftContentView(
store: Store(
initialState: ContentFeature.State(),
reducer: {
ContentFeature()
}
)
)
もし、state の初期値がない場合は、State()の中に値を記述してあげましょう。
View から Reducer の State・Action を呼び出す方法
Reducer と View を紐づけられたので、View から Reducer の State・Action を呼び出してみます。
ContentView.swift@Reducer
struct ContentFeature {
@ObservableState
struct State {
var sampleState: String = "hogehoge"
}
enum Action {
case sampleAction
case sampleAction2(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .sampleAction:
print("Hello, world!")
return .none
case let .sampleAction2(text):
state.sampleState = text
return .none
}
}
}
}
struct ContentView: View {
let store: StoreOf<ContentFeature>
var body: some View {
VStack {
// Stateは「store.変数名」
Text(store.sampleState)
Button(action: {
// Actionは「store.send(.アクション名)」
store.send(.sampleAction)
}, label: {
Text("アクション呼び出し")
})
}
}
}
Binding 型を使いたい場合
アプリ開発をする上で、Binding 型を使う場面は数多くあると思います。
たとえば TextField とか
// 「$」がついているのがBinding型
TextField("placeholder", text: $text)
通常、変数をどこかへ渡す際は、その変数の値を読む権限しか与えないけど、Binding は、読む権限に加えて、値を更新する権限を渡しているイメージです。
まずは、ContentView にある store を Bindable にします。
ContentView.swiftstruct ContentView: View {
@Bindable var store: StoreOf<ContentFeature>
var body: some View {
// ...
}
}
これで View で State を Binding 型として呼び出せるよになりました。
つぎは、値を更新するアクションを書きます。
ContentView.swift@Reducer
struct ContentFeature {
@ObservableState
struct State {
// 入力するテキスト
var text: String = ""
}
enum Action {
// このアクションを使う権限を与えてあげるイメージ
case textChanged(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
// 新しい値が入ってきたら値を更新する
case let .textChanged(newText):
state.text = newText
return .none
}
}
}
}
Reducer 側で作ったアクションを TextField に渡してあげます。
ContentView.swiftstruct ContentView: View {
@Bindable var store: StoreOf<ContentFeature>
var body: some View {
VStack {
// 値と、値を更新するアクションを渡す
TextField("placeholder", text: $store.text.sending(\.textChanged))
}
}
}
もっと簡単に Binding 型を使う方法(こっちの方が基本)
さきほどの方法だと、Binding の数だけアクション数を増やさないといけないのが面倒です。
もっと簡単に Binding 型を使う方法があるのでざっと書きます。
ContentView.swift@Reducer
struct ContentFeature {
@ObservableState
struct State {
var text: String = ""
var text2: String = ""
var text3: String = ""
}
// ActionをBindableActionに準拠させる
enum Action: BindableAction {
// 更新用のアクション(アクション名はなんでもいい)
case binding(BindingAction<State>)
}
var body: some ReducerOf<Self> {
// 追記
BindingReducer()
Reduce { state, action in
switch action {
case .binding:
return .none
}
}
}
}
struct ContentView: View {
@Bindable var store: StoreOf<ContentFeature>
var body: some View {
VStack(spacing: 30) {
// アクションを指定しなくていい!!
TextField("placeholder", text: $store.text)
TextField("placeholder", text: $store.text2)
TextField("placeholder", text: $store.text3)
}
}
}
もし値を更新する時に別の処理を行いたい時は ↓
ContentView.swiftvar body: some ReducerOf<Self> {
// 追記
BindingReducer()
Reduce { state, action in
switch action {
// text2が更新された時だけに呼ばれる
case .binding(\.text2):
print("text2が更新されたよ")
return .none
case .binding:
return .none
}
}
}
テキスト入力しながら検索走らせる時とかに便利ですね。
TCA で副作用(Side-Effects)を扱う方法
ここからは、Reducer 作成時に書いていた Action の「return .none」について触れていきます。
副作用ってなに???
私が最初に聞いた時は薬の副作用しか連想できませんでした。。
「副作用」で検索すると関数の外がどうのこうの書いてあり、戸惑うかもしれません。
TCA における副作用は、以下の 3 つでよく使われます。
- API 通信
- タイマー処理
- ファイルの読み書き
どれも、アプリにおけるメインのシステムの外に影響を与えていますね。
その影響のことを「副作用」と呼んでいます。
最初のうちは、他の関数を呼び出すのが副作用と覚えても、あまり問題ないかもしれないです。
非同期処理の実装方法
Reducer 作成時に「return .none」と書いていましたが、この記述をすると、「副作用がない」という意味になります。
ここでは、.none 以外を返す、つまり、副作用を持ったアクションの書き方を説明します。
例として、API を叩く副作用を書いてみましょう
数字の雑学を教えてくれる、NUMBERS APIを使用します。
*http通信の許可が必要です
参考:https://qiita.com/Howasuto/items/f8e97796c6eb30de4112
ContentView.swiftstruct ContentView: View {
let store: StoreOf<ContentFeature>
var body: some View {
VStack {
Text(store.responseText)
Button(action: {
store.send(.performApi)
}, label: {
Text("Button")
})
}
}
}
@Reducer
struct ContentFeature {
@ObservableState
struct State {
// API送信のために使う値
var count = 3
// APIで返ってきた値
var responseText = ""
}
enum Action {
// APIを叩くアクション
case performApi
// APIからレスポンスが帰ってきた時に実行するアクション
case apiResponse(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .performApi:
// return .runで副作用があることを宣言
// [count = state.count]とすることで、副作用があるアクションの中でStateの値が使えるようにする
return .run { [count = state.count] send in
// リクエスト作成 & API実行
let (data, _) = try await URLSession.shared
.data(from: URL(string: "http://numbersapi.com/\(count)")!)
// 帰ってきた文字列をデコードする
let fact = String(decoding: data, as: UTF8.self)
// 副作用のあるアクションではStateを更新できないので、Stateを更新するために他のアクションを呼び出す
await send(.apiResponse(fact))
}
case let .apiResponse(text):
// State更新
state.responseText = text
print(state.responseText)
return .none
}
}
}
}
ポイントは 3 つ
- 副作用がある処理は「return .run { 処理 }」と書く
- 副作用があるアクションでは、State の値にアクセスできない
- 返り値を使って State をいじる場合は、レスポンス時のアクションを用意して、そこに値を渡す
TCA でナビゲーションの実装方法
- モーダル遷移
- ページ遷移(NavigationLink)
に分けて書きます。
モーダル遷移
まずは遷移先で出す View と、Reducer を作成します。
ModalView.swift// View
struct ModalView: View {
let store: StoreOf<ModalFeature>
var body: some View {
VStack {
Text("モーダル")
}
}
}
// Reducer
@Reducer
struct ModalFeature {
@ObservableState
struct State {}
enum Action {}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {}
}
}
}
ContentView にて、モーダルを出すコードを記述していきます。
View にあるボタンが押されたらモーダルを出そうと思います。
ContentView.swift// View
struct ContentView: View {
@Bindable var store: StoreOf<ContentFeature>
var body: some View {
VStack {
Button(action: {
// modalを出すアクションを呼び出す
store.send(.buttonTapped)
}, label: {
Text("モーダル表示")
})
}
// シート
.sheet(
item: $store.scope(state: \.modal, action: \.modal)
) { modalStore in
ModalView(store: modalStore)
}
}
}
// Reducer
@Reducer
struct ContentFeature {
@ObservableState
struct State {
@Presents var modal: ModalFeature.State?
}
enum Action {
// モーダルを出すボタンがタップされた時
case buttonTapped
// モーダルReducerにあるアクションの呼び出しを検知してくれるヤツ
case modal(PresentationAction<ModalFeature.Action>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .buttonTapped:
// ① state内の「modal」に値を入れる。
// ModalFeatureに初期値が未設定の変数がある場合、初期値を入れる
state.modal = ModalFeature.State()
return .none
case .modal:
return .none
}
}
// ② state内の「modal」に値がが入ったら、Reducerの中身を書き換える
.ifLet(\.$modal, action: \.modal) {
ModalFeature()
}
}
}
ナビゲーション遷移
まずは遷移先で出す View と、Reducer を作成します。
DestinationView.swift// View
struct DestinationView: View {
let store: StoreOf<DestinationFeature>
var body: some View {
VStack {
Text("Destination")
}
}
}
// Reducer
@Reducer
struct DestinationFeature {
@ObservableState
struct State {}
enum Action {}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {}
}
}
}
ContentView にて、DestinationViewを出すコードを記述していきます。
View にあるリンクが押されたらDestinationViewを出そうと思います。
ContentView.swift// View
struct ContentView: View {
@Bindable var store: StoreOf<ContentFeature>
var body: some View {
// 親ViewをNavigationStackで囲む
NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
VStack {
NavigationLink(state: DestinationFeature.State()) {
Text("ナビゲーション遷移")
}
}
} destination: { destinationStore in
// 遷移できる移動先を指定
DestinationView(store: destinationStore)
}
}
}
// Reducer
@Reducer
struct ContentFeature {
@ObservableState
struct State {
@Presents var destination: DestinationFeature.State?
var path = StackState<DestinationFeature.State>()
}
enum Action {
// 遷移先に行くボタンがタップされた時
case buttonTapped
// 子Viewのアクションを
case destination(PresentationAction<DestinationFeature.Action>)
case path(StackAction<DestinationFeature.State, DestinationFeature.Action>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .buttonTapped:
// ① state内の「destination」に値を入れる。
// DestinationFeatureに初期値が未設定の変数がある場合、初期値を入れる
state.destination = DestinationFeature.State()
return .none
case .destination:
return .none
case .path:
return .none
}
}
// ② state内の「destination」に値がが入ったら、このReducerの中身を書き換える
.forEach(\.path, action: \.path) {
DestinationFeature()
}
}
}
おわりに
かなり端折って説明しましたが、基礎の基礎は抑えられてるはずです。
この記事のことを念頭に置いて、公式のチュートリアルなどを読めば、ある程度理解が深まるはずです...多分!
参考
🙂↕️最後まで読んでいただきありがとうございます🙂↕️