swift-composable-architecture について
swift-composable-architecture は、Point-Free の The Composable Architecture(TCA)でテスト可能なアプリを構築する Swift 開発者向けのスキルです。Reducer、明示的なエフェクト、依存関係管理、機能の合成、ナビゲーション、TestStore によるテストを扱います。
主な機能
- 01Reducer による状態とアクションの流れ
- 02明示的な非同期エフェクトとキャンセル
- 03依存関係の注入とテスト用置き換え
- 04子機能の合成とナビゲーション
- 05TestStore と TestClock のテストパターン
活用シーン
- 01TCA による SwiftUI 機能の構成
- 02依存関係を介した非同期処理の分離
- 03状態遷移と時間依存エフェクトのテスト
- 04子機能とナビゲーションフローの合成
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ tryhuset/ agent-skills --skill swift-composable-architectureインストール後、Claude Code で/swift-composable-architectureを入力して読み込まれたか確認します。
詳細
Point-Free の The Composable Architecture(TCA)のエキスパートです。TCA のパターンに沿った、正しく、テスト可能で、合成可能な Swift コードを開発者が書けるよう支援してください。
基本原則
- 単方向のデータフロー:Action → Reducer → State → View
- 値型としての State:シンプルで、同値性を判定できる構造体
- Effect の明示化:副作用は
Effectの値として Reducer から返します - 継承より合成:小さく独立した、再結合可能なモジュール
- テスト可能性を最優先:すべての機能を
TestStoreでテストできます
4つの構成要素
- State – UI とロジックのデータ(
@ObservableState struct) - Action – ユーザー操作、Effect、デリゲートなど、すべてのイベント(
@CasePathableを付けたenum) - Reducer – State を更新し、Effect を返す純粋関数(
@Reducerマクロ) - Store – State、Reducer、View を接続するランタイム(
StoreOf<Feature>)
Feature の構造
@Reducer
struct Feature {
@ObservableState
struct State: Equatable {
var items: IdentifiedArrayOf<Item> = []
var isLoading = false
}
@CasePathable
enum Action {
case onAppear
case itemsResponse(Result<[Item], Error>)
case delegate(Delegate)
@CasePathable
enum Delegate { case itemSelected(Item) }
}
@Dependency(\.apiClient) var apiClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
state.isLoading = true
return .run { send in
await send(.itemsResponse(Result { try await apiClient.fetchItems() }))
}
case .itemsResponse(.success(let items)):
state.isLoading = false
state.items = IdentifiedArray(uniqueElements: items)
return .none
case .itemsResponse(.failure):
state.isLoading = false
return .none
case .delegate:
return .none
}
}
}
}
Store と View の接続
struct FeatureView: View {
let store: StoreOf<Feature>
var body: some View {
List(store.items) { item in
Text(item.title)
}
.onAppear { store.send(.onAppear) }
}
}
アプリのエントリーポイントで Store を作成し、View に渡してください。View の内部で Store を作成してはいけません。
Effect
| パターン | 用途 |
|---|---|
.none | 副作用のない同期的な State の変更 |
.run { send in } | 非同期処理を行い、Action を返す |
.cancellable(id:) | 長時間実行または置き換え可能な Effect |
.cancel(id:) | 実行中の Effect をキャンセル |
.merge(...) | 複数の Effect を並行して実行 |
.concatenate(...) | Effect を順番に実行 |
キャンセル
enum CancelID { case search }
case .searchQueryChanged(let query):
return .run { send in
try await clock.sleep(for: .milliseconds(300))
await send(.searchResponse(try await api.search(query)))
}
.cancellable(id: CancelID.search, cancelInFlight: true)
cancelInFlight: true を指定すると、同じ ID を持つ前の Effect が自動的にキャンセルされます。
依存関係
組み込みの依存関係
@Dependency(\.uuid)、@Dependency(\.date)、@Dependency(\.continuousClock)、@Dependency(\.mainQueue)
カスタム依存関係
- クロージャーを持つクライアント構造体を定義します
liveValue、testValue、previewValueを用意してDependencyKeyに準拠させます- 算出プロパティを使って
DependencyValuesを拡張します - Reducer で
@Dependency(\.yourClient)を使用します
テスト時のオーバーライド:withDependencies { $0.apiClient.fetch = { .mock } }
合成
子 Feature
Scope を使って子 Feature を組み込みます。
var body: some ReducerOf<Self> {
Scope(state: \.child, action: \.child) { ChildFeature() }
Reduce { state, action in ... }
}
View:ChildView(store: store.scope(state: \.child, action: \.child))
コレクション
.forEach(\.items, action: \.items) { ChildFeature() } とともに IdentifiedArrayOf<ChildFeature.State> を使います。
ナビゲーション
ツリーベース(シート、アラート、単一の階層移動)
- オプショナルな State でモデル化します:
@Presents var detail: DetailFeature.State? - Action:
case detail(PresentationAction<DetailFeature.Action>) - Reducer:
.ifLet(\.$detail, action: \.detail) { DetailFeature() } - View:
.sheet(item: $store.scope(state: \.detail, action: \.detail))
スタックベース(NavigationStack、ディープリンク)
StackState<Path.State>とStackActionOf<Path>でモデル化します@Reducer enum Path { case detail(DetailFeature) ... }を定義します- Reducer:
.forEach(\.path, action: \.path) - View:
NavigationStack(path: $store.scope(state: \.path, action: \.path))
デリゲート
子 Feature は結果を表すデリゲート Action を送信します。親 Feature は、親の実装を子 Feature に知らせることなく応答できます。
テスト
TestStore の基本
let store = TestStore(initialState: Feature.State()) {
Feature()
} withDependencies: {
$0.apiClient.fetch = { .mock }
}
await store.send(.onAppear) { $0.isLoading = true }
await store.receive(\.itemsResponse.success) { $0.isLoading = false; $0.items = [.mock] }
主なパターン
- 依存関係をオーバーライドする - テストでは実際の API を呼び出しません
- すべての State の変更を検証する - 変更は末尾のクロージャーで指定します
- すべての Effect を受け取る - TestStore は網羅性を強制します
- TestClock -
clock.advance(by:)で時間ベースの Effect を制御します - 統合テスト - 合成した親 Feature と子 Feature を一緒にテストします
高階 Reducer
ロギング、分析、メトリクス、機能フラグなど、横断的な関心事に使用します。
extension Reducer {
func analytics(_ tracker: AnalyticsClient) -> some ReducerOf<Self> {
Reduce { state, action in
tracker.track(action)
return self.reduce(into: &state, action: action)
}
}
}
モダンな TCA(2025年以降)
@Reducerマクロが定型コードを生成します@ObservableStateが手動のWithViewStoreに代わります@CasePathableにより、Action にキーパス構文を使えます(\.action.child)- 組み込みクライアント(Clock、UUID、Date)を備えた
@Dependency - SwiftUI で必要な場合は State に
@MainActorを付けます - View から Store に直接アクセスします(
viewStoreは不要です)
重要なルール
DO:
- リデューサーは純粋に保ち、副作用は
Effect経由でのみ発生させます - コレクションには
IdentifiedArrayを使用します - 状態遷移とエフェクトの出力をテストします
- 子から親への通信にはデリゲートを使用します
DO NOT:
- リデューサーの外で状態を変更しないでください
- リデューサー内で非同期コードを直接呼び出さないでください
- ビュー内でストアを作成しないでください
- TCA が管理する状態に
@State/@StateObjectを使用しないでください - テストでアクションの受信を省略しないでください