geDemのアバターアイコン
geDem
 Blog
記事のカバー写真

TCA を初心者にわかりやすく解説してみる試み

geDemのアバターアイコン
geDem
ペン

最近、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()
            }
    }
}

おわりに

かなり端折って説明しましたが、基礎の基礎は抑えられてるはずです。

この記事のことを念頭に置いて、公式のチュートリアルなどを読めば、ある程度理解が深まるはずです...多分!

TCA チュートリアル

参考

https://pointfreeco.github.io/swift-composable-architecture/main/tutorials/meetcomposablearchitecture/

🙂‍↕️最後まで読んでいただきありがとうございます🙂‍↕️