🌟

TCAにおけるInternalActionの誤用問題

に公開

はじめに

TCA(The Composable Architecture)でのAction設計において、以下の3つに分割する手法が採用されることがあります:

@Reducer
public struct SomeFeature {
  public enum Action: Equatable {
    case view(ViewAction)
    case internal(InternalAction)
    case `delegate`(DelegateAction)
  }
}

この分割手法において、InternalActionの誤用により、実行順序が保証されない処理や予期しない状態変更が発生する場合があります。

問題の根本原因

TCAの技術仕様:sendメソッドの動作特性

InternalActionの誤用問題の根本には、sendメソッドの動作特性への理解不足があります。

sendメソッドは、呼び出し先のアクションがエフェクトを返すまでしか待機せず、.runメソッド内の非同期処理の完了は待ちません。

// await send()の実際の動作
case .internal(.processData):
  return .run { send in
    await send(.internal(.step1))  // step1がエフェクトを返すまで待機
    await send(.internal(.step2))  // step2がエフェクトを返すまで待機
    await send(.internal(.step3))  // step3がエフェクトを返すまで待機
    // ここで.processDataの.runは完了
    // しかし各stepの.runメソッド内の非同期処理はこれから別々に開始される可能性がある
  }

case .internal(.step1):
  state.isProcessing = true  // ← ここは実行される(同期処理)
  return .run { send in  // ← ここでreturnされ、awaitが解除される
    // ⚠️ この後の処理はWorkerが実行する
    // ⚠️ step2、step3の.runメソッド内の非同期処理と同時に実行される可能性がある
    let result = await heavyProcess1()
    await send(.internal(.step1Completed(result)))
  }

一般的な誤解

一般的な非同期処理ではawaitは処理完了まで待機するため、初学者は以下のように誤解しがちです:

  • 「InternalActionを順番に送信すれば、各InternalActionの.runメソッド内の非同期処理も順番に実行されるはず」
  • await send(.internal(.step1))は、step1の.runメソッド内の非同期処理の完了まで待つ」
  • 「依存関係のある処理を複数のInternalActionに分けても、実行順序が保たれるはず」

実際の動作

  • await send().runメソッドの戻り値取得まで待機
  • .runメソッド内の非同期処理はWorkerが実行
  • 複数の.runメソッド内の非同期処理は逐次開始されるが、実行順序は不定

誤用パターンと問題

「共通アクション」としての誤用

InternalActionを「複数の場所から呼び出せる共通処理」として理解した場合の問題例:

// ❌ 問題のある実装例
public func reduce(into state: inout State, action: Action) -> Effect<Action> {
  switch action {
  case .view(.tapSaveButton), .view(.tapSubmitButton):
    return .send(.internal(.validateAndSubmit))

  case .internal(.validateAndSubmit):
    return .run { send in
      // 複数のInternalActionを連続送信
      await send(.internal(.startValidation))
      await send(.internal(.startSubmission))
      await send(.internal(.startCleanup))
    }

  case .internal(.startValidation):
    return .run { send in
      let isValid = await validateUserData()
      await send(.internal(.validationResult(isValid)))
    }

  case .internal(.startSubmission):
    return .run { send in
      // バリデーション結果を待たずに実行される可能性がある
      let result = await submitToServer(state.userData)
      await send(.internal(.submissionResult(result)))
    }

  case .internal(.startCleanup):
    return .run { send in
      // バリデーションと送信の完了を待たずに実行される可能性がある
      await cleanupTempFiles()
      await send(.internal(.cleanupCompleted))
    }
  }
}

発生する可能性がある問題:

  • バリデーション完了前に送信処理やクリーンアップ処理が開始される可能性
  • 処理間の依存関係が保証されない場合がある
  • 状態の不整合やデータ競合が発生する可能性

依存関係のある処理の分割

依存関係のある処理を複数のInternalActionに分けた場合の問題例:

// ❌ 逐次実行を期待したコード
case .view(.tapComplexOperation):
  return .run { send in
    await send(.internal(.step1)) // step1を開始
    await send(.internal(.step2)) // step2を開始
    await send(.internal(.step3)) // step3を開始
  }

case .internal(.step1):
  return .run { send in
    let result = await processLargeFile()
    await send(.internal(.step1Completed(result)))
  }

case .internal(.step3):
  return .run { send in
    // step1とstep2の結果に依存する処理
    // ⚠️ しかし、step1、step2の完了を待たずに実行される可能性がある
    let finalResult = await combineResults(state.step1Result, state.step2Result)
    await send(.internal(.operationCompleted(finalResult)))
  }

実際の実行結果:

  • step1、step2、step3のInternalActionが順次送信される
  • 各InternalActionの.runメソッドがWorkerで実行されるが、実行順序は保証されない
  • step3のcombineResults()が、step1やstep2よりも先に実行される可能性がある
  • state.step1Resultstate.step2Resultが未設定のまま参照され、予期しない結果となる

提案

提案1:InternalActionをStateActionに変更する

ルール: InternalActionという誤解を与えそうなアクション定義をやめて、状態更新を行うStateActionに変更する

理由: StateActionは状態更新のみを担当し、.runメソッドを持たないため、完璧に誤用を防げるわけではないが、実行順序の問題が構造的に発生しにくくなります。

// ✅ 改善されたAction定義
@Reducer
public struct SafeFeature {
  public enum Action: Equatable {
    case view(ViewAction)
    case state(StateAction)      // InternalActionの代わり
    case `delegate`(DelegateAction)
  }

  public enum StateAction: Equatable {
    case validationCompleted(Bool)
    case networkResponse(Result<Data, Error>)
    case operationCompleted
    // 状態更新のみを担当(.runメソッドを持たない)
  }
}

提案2:共通非同期処理はasync funcで定義する

ルール: 実装ルールとして、共通非同期処理はasync funcで定義するという明文化

理由: 単一の.runメソッド内でasync/awaitを使用することで、処理の実行順序が保証されます。

// ✅ 共通非同期処理の実装例
@Reducer
public struct SafeFeature {

  // 共通の非同期処理をasync funcで定義
  private func performValidationAndSubmit(
    userData: UserData,
    send: Send<Action>
  ) async {
    // 1. バリデーション(シーケンシャル実行保証)
    let isValid = await validateData(userData)
    await send(.state(.validationCompleted(isValid)))

    guard isValid else { return }

    // 2. ネットワーク送信(バリデーション完了後に実行)
    let result = await apiClient.submitData(userData)
    await send(.state(.networkResponse(result)))
  }

  public func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .view(.tapSaveButton), .view(.tapSubmitButton):
      // 共通処理を呼び出し(シーケンシャル実行保証)
      return .run { send in
        await performValidationAndSubmit(
          userData: state.userData,
          send: send
        )
      }

    case .state(.validationCompleted(let isValid)):
      state.isValidating = false
      state.isValid = isValid
      return .none  // StateActionは状態更新のみ
    }
  }
}

終わりに

InternalActionの誤用問題は、2つのシンプルなルールで構造的に改善できます:

  1. InternalAction → StateAction: 状態更新専用のアクション定義
  2. 共通非同期処理 → async func: 処理順序を保証する実装ルール

これらのルールを採用することで、実行順序バグや競合状態を防ぎ、安全で保守しやすいTCAアプリケーションを構築できます。

この記事は2025年11月に執筆されました。TCA 1.0系での実装を前提としています。

Discussion