【SwiftUI】TCAの公式チュートリアルを試してみた

## はじめに
TCAとはTheComposableArchitectureの略です。
一言でいうと状態管理とビジネスロジックをモジュール化して簡潔にするアーキテクチャ用のライブラリです。話には聞いていたのですが触れたことがないため試しにチュートリアルを行ってみました。

## 結論
チュートリアルの完成度が高く、TCA入門にはこれが最適だと感じました。

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

公式ドキュメント

https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/


## そもそもTCAとは
> 「SwiftUI版のRedux」 です。
Reduxは、Flux のアーキテクチャを、副作用のない純粋関数によって実現するアーキテクチャー であり、ライブラリです。

下記記事を引用させていただきました。とても参考になった記事です。

https://qiita.com/karamage/items/f63a5750e65c5c9745ae

# チュートリアル
早速チュートリアルを始めてみます。まずは[ライブラリ](https://github.com/pointfreeco/swift-composable-architecture)を導入します。

## Reducerを作成する

> Composable Architecture で機能が構築される基本単位は、Reducer()マクロとReducerプロトコルです。
> 最も重要なのは、機能のコアロジックと動作は、SwiftUI ビューに言及することなく完全に分離して構築できるため、分離して開発しやすくなり、再利用しやすくなり、テストしやすくなることです。

```Swift
import SwiftUI
import ComposableArchitecture

@Reducer
struct CounterFeature {
    @ObservableState
    struct State {
        var count = 0
    }

    enum Action {
        // ユーザーが実際に行う操作に基づいて命名
        case decrementButtonTapped
        case incrementButtonTapped
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .decrementButtonTapped:
                state.count -= 1
                return .none

            case .incrementButtonTapped:
                state.count += 1
                return .none
            }
        }
    }
}


struct ContentView: View {

    let store: StoreOf<CounterFeature>

    var body: some View {
        VStack {
            Text("\(store.count)")
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)
            HStack {
                Button("-") {
                    store.send(.decrementButtonTapped)
                }
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)

                Button("+") {
                    store.send(.incrementButtonTapped)
                }
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)
            }
        }
    }
}

#Preview {
    ContentView(
        store: Store(initialState: CounterFeature.State()) {
            CounterFeature()
        }
    )
}
```

![スクリーンショット 2024-06-19 6.15.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/70293edd-151e-b4f6-b51d-9c6d19a15f32.png)

## side effectsを追加する

> side effectsは、機能開発において最も重要な側面です。API リクエストの作成、ファイル システムとのやり取り、非同期の実行など、外部との通信を可能にするものです。

https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/effect/run(priority:operation:catch:fileid:line:)

```Swift
@Reducer
struct CounterFeature {
    @ObservableState
    struct State {
        var count = 0
        var fact: String?
        var isLoading = false
    }

    enum Action {
        // ユーザーが実際に行う操作に基づいて命名
        case decrementButtonTapped
        case incrementButtonTapped
        case factButtonTapped
        // APIレスポンスもActionとする
        case factResponse(String)
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .decrementButtonTapped:
                state.count -= 1
                state.fact = nil
                return .none

            case .factButtonTapped:
                state.fact = nil
                state.isLoading = true
                // runで非同期処理を実行
                // NumbersAPI : 数字にまつわるトリビアを取得する
                // ※http通信なのでinfo.plistでATSS許可する
                return .run { [count = state.count] send in
                    let (data, _) = try await URLSession.shared
                        .data(from: URL(string: "http://numbersapi.com/\(count)")!)
                    let fact = String(decoding: data, as: UTF8.self)
                    await send(.factResponse(fact))
                }

            case let .factResponse(fact):
                state.fact = fact
                state.isLoading = false
                return .none

            case .incrementButtonTapped:
                state.count += 1
                state.fact = nil
                return .none
            }
        }
    }
}


struct ContentView: View {
    let store: StoreOf<CounterFeature>

    var body: some View {
        VStack {
            Text("\(store.count)")
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)
            HStack {
                Button("-") {
                    store.send(.decrementButtonTapped)
                }
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)

                Button("+") {
                    store.send(.incrementButtonTapped)
                }
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)
            }

            Button("Fetch") {
                store.send(.factButtonTapped)
            }
            .font(.largeTitle)
            .padding()
            .background(Color.black.opacity(0.1))
            .cornerRadius(10)

            if store.isLoading {
                ProgressView()
            } else if let fact = store.fact {
                // 数字トリビア表示
                Text(fact)
                    .font(.largeTitle)
                    .multilineTextAlignment(.center)
                    .padding()
            }
        }
    }
}

#Preview {
    ContentView(
        store: Store(initialState: CounterFeature.State()) {
            CounterFeature()
        }
    )
}
```

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/791b51a4-41f8-b25b-099a-cb9ef7ab87ee.gif">


### タイマーの追加
1秒毎にカウントが1増える機能を追加します。

```Swift
@Reducer
struct CounterFeature {
    @ObservableState
    struct State {
        var count = 0
        var fact: String?
        var isLoading = false
        var isTimerRunning = false
    }

    enum Action {
        // ユーザーが実際に行う操作に基づいて命名
        case decrementButtonTapped
        case incrementButtonTapped
        case factButtonTapped
        // APIレスポンスもActionとする
        case factResponse(String)
        // タイマー
        case timerTick
        case toggleTimerButtonTapped
    }

    // エフェクトのキャンセルに使用する
    enum CancelID { case timer }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .decrementButtonTapped:
                state.count -= 1
                state.fact = nil
                return .none

            case .incrementButtonTapped:
                state.count += 1
                state.fact = nil
                return .none

            case .factButtonTapped:
                state.fact = nil
                state.isLoading = true
                // runで非同期処理を実行
                // NumbersAPI : 数字にまつわるトリビアを取得する
                return .run { [count = state.count] send in
                    let (data, _) = try await URLSession.shared
                        .data(from: URL(string: "http://numbersapi.com/\(count)")!)
                    let fact = String(decoding: data, as: UTF8.self)
                    await send(.factResponse(fact))
                }

            case let .factResponse(fact):
                state.fact = fact
                state.isLoading = false
                return .none

            case .timerTick:
                state.count += 1
                state.fact = nil
                return .none

            case .toggleTimerButtonTapped:
                state.isTimerRunning.toggle()
                if state.isTimerRunning {
                    return .run { send in
                        while true {
                            try await Task.sleep(for: .seconds(1))
                            // タイマータスクの処理を呼ぶ
                            await send(.timerTick)
                        }
                    }
                    .cancellable(id: CancelID.timer)
                } else {
                    return .cancel(id: CancelID.timer)
                }
            }
        }
    }
}


struct ContentView: View {
    let store: StoreOf<CounterFeature>

    var body: some View {
        VStack {
            Text("\(store.count)")
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)
            HStack {
                Button("-") {
                    store.send(.decrementButtonTapped)
                }
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)

                Button("+") {
                    store.send(.incrementButtonTapped)
                }
                .font(.largeTitle)
                .padding()
                .background(Color.black.opacity(0.1))
                .cornerRadius(10)
            }

            Button(store.isTimerRunning ? "Stop timer" : "Start timer") {
                store.send(.toggleTimerButtonTapped)
            }
            .font(.largeTitle)
            .padding()
            .background(Color.black.opacity(0.1))
            .cornerRadius(10)

            Button("fact") {
                store.send(.factButtonTapped)
            }
            .font(.largeTitle)
            .padding()
            .background(Color.black.opacity(0.1))
            .cornerRadius(10)

            if store.isLoading {
                ProgressView()
            } else if let fact = store.fact {
                // 数字トリビア表示
                Text(fact)
                    .font(.largeTitle)
                    .multilineTextAlignment(.center)
                    .padding()
            }
        }
    }
}

#Preview {
    ContentView(
        store: Store(initialState: CounterFeature.State()) {
            CounterFeature()
        }
    )
}
```

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201315/aa290c23-ae4d-3b8c-aee3-c455ac7344f8.gif">

## 最後に
私の働いている会社で経験の有無を問わず採用を行っています。
興味のある方は是非カジュアル面談から応募してみてください！

https://jambo-support.com/recruit_engineer/