mcpField
S
swift-composable-architecture

swift-composable-architecture

github.com/tryhuset開発655 回インストール23 週間前に更新
リポジトリ

swift-composable-architecture について

swift-composable-architecture は、Point-Free の The Composable Architecture(TCA)でテスト可能なアプリを構築する Swift 開発者向けのスキルです。Reducer、明示的なエフェクト、依存関係管理、機能の合成、ナビゲーション、TestStore によるテストを扱います。

主な機能

  1. 01Reducer による状態とアクションの流れ
  2. 02明示的な非同期エフェクトとキャンセル
  3. 03依存関係の注入とテスト用置き換え
  4. 04子機能の合成とナビゲーション
  5. 05TestStore と TestClock のテストパターン

活用シーン

  1. 01TCA による SwiftUI 機能の構成
  2. 02依存関係を介した非同期処理の分離
  3. 03状態遷移と時間依存エフェクトのテスト
  4. 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つの構成要素

  1. State – UI とロジックのデータ(@ObservableState struct)
  2. Action – ユーザー操作、Effect、デリゲートなど、すべてのイベント(@CasePathable を付けた enum)
  3. Reducer – State を更新し、Effect を返す純粋関数(@Reducer マクロ)
  4. 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)

カスタム依存関係
  1. クロージャーを持つクライアント構造体を定義します
  2. liveValue、testValue、previewValue を用意して DependencyKey に準拠させます
  3. 算出プロパティを使って DependencyValues を拡張します
  4. 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 を使用しないでください
  • テストでアクションの受信を省略しないでください

コメント