Angularの状態管理4系統をレビューで選び分ける判断軸

Angular 22 のアプリケーションで状態を持つ手段は、大きく4系統ある。Signal、RxJS、NgRx Store、そして SignalStore だ。

やっかいなのは、ひとつの要件に対して複数の系統が正解になりうる点である。一覧の絞り込みは computed() でも combineLatest でも Selector でも書ける。どれもコンパイルは通り、テストも書ける。選択を誤ったコードが表面化するのは、たいてい本番で特定の条件に入ったときだ。

レビューアーが問うべきなのは、どのAPIを使っているかではない。そのAPIの契約に、書かれている処理が収まっているかである。

4系統がそれぞれ表現するもの

4つは守備範囲が違う。

系統 表現するもの 代表的なAPI
Signal 現在値と、そこから決まる派生値 signal() / computed() / linkedSignal()
RxJS 時間軸上を流れる通知と、その合成・競合制御 switchMap / combineLatest / debounceTime
NgRx Store Actionを軸とする状態遷移 createAction / createReducer / createSelector
NgRx SignalStore Signalを軸に構造化した状態と操作 withState / withComputed / withMethods

この4つは、どれかを選んだら他を捨てるという関係にない。SignalStore で現在値を持ちながら、非同期の競合制御だけ RxJS に任せる構成は普通に成り立つ。したがって、ひとつの機能の中で系統が混在していること自体は、指摘の理由にならない。混ざり方が要件と噛み合っているかどうかだけが論点になる。

「Signalへ移せばRxJSの問題は消える」という誤解

RxJS の学習コストを嫌って Signal へ寄せる判断は、それ自体はおかしくない。問題は、RxJS が担っていた責務ごと消せると考えてしまう場合にある。

非同期の競合をsignalだけで扱おうとした実装
@Component({ /* ... */ })
export class UserSearchComponent {
  readonly keyword = signal('');
  readonly users = signal<User[]>([]);

  private readonly api = inject(UserApi);

  onKeywordChange(value: string): void {
    this.keyword.set(value);
    this.api.search(value).subscribe(users => this.users.set(users));
@Reviewer
入力のたびに検索を投げていますが、先行するリクエストを打ち切る仕組みがありません。応答が到着順に `set` されるため、古い検索結果が新しい結果を上書きする場合があります。
} }

このコードは動く。開発環境で試すぶんには、たいてい期待どおりに見える。手元で操作している限り、前の応答が返ってから次を入力することになり、リクエストが重ならないからだ。

それでも壊れるのは、HTTPの応答順が送信順と一致しないからだ。ab と入力してから abc と入力した場合、2本のリクエストが並行して飛ぶ。abc の応答が先に返り、ab の応答が後から返れば、set は後から来た ab の結果を書き込む。表示は abc の検索結果ではなくなる。

Signal にはこれを防ぐ仕組みがない。signal() が表現するのは現在値であって、通知の前後関係や打ち切りではないからだ。打ち切りを表現できるのは RxJS の側である。

競合制御をRxJSへ戻した実装
@Component({ /* ... */ })
export class UserSearchComponent {
  readonly keyword = signal('');

  private readonly api = inject(UserApi);

  readonly users = toSignal(
    toObservable(this.keyword).pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap(value => this.api.search(value)),
    ),
    { initialValue: [] as User[] },
  );
}

書き換えても Signal をやめたわけではない。keywordusers も Signal のままで、テンプレートからの読み方は変わらない。変わったのは、検索という「重なりうる非同期処理」を RxJS の担当へ戻した点だけである。

Comment
@Reviewer: 検索のように後続の入力が先行リクエストを無効にする処理は、Signalだけでは順序を保証できません。`toObservable` で通知に変換し、`switchMap` で打ち切ってから `toSignal` で戻す形を検討してください。

選択を分ける4つの問い

系統の割り当ては、次の4つを順に問えばおおむね決まる。

値がひとつなのか、通知が続くのか

いま何であるかだけを知りたいなら Signal の領域になる。フォームの入力値、開閉フラグ、選択中のタブは、過去にどう変わったかを保持する必要がない。

一方、値が時間とともに何度も届き、その届き方(間隔、順序、重なり)自体を扱う必要があるなら RxJS になる。入力のたびに走る検索、WebSocket の受信、ポーリングがこれにあたる。

Comment
@Reviewer: ここで扱いたいのは現在値だけに見えます。`BehaviorSubject` を使う理由が購読の共有以外にあれば教えてください。なければ `signal()` のほうが読みやすくなります。

非同期が重なったとき何を捨てるか

非同期処理が並行しうるなら、重なったときの扱いを決めなければならない。前を打ち切るのか、順番に処理するのか、実行中の後続を無視するのか、並行させるのか。switchMap / concatMap / exhaustMap / mergeMap は、この問いへの4通りの答えである。

選択が要件と合っていないコードは、検索の例と同じ理由で、負荷が低いうちは正しく見える。レビューで確認しておかないと、本番の通信遅延ではじめて表面化する。

Comment
@Reviewer: 保存処理に `mergeMap` を使っているため、ボタンの連打でリクエストが多重に飛びます。実行中は後続を捨てる意図なら `exhaustMap`、順に処理する意図なら `concatMap` が合います。

状態が変わった理由を残す必要があるか

「何が起きて状態が変わったのか」を記録し、ひとつの出来事に複数の箇所が反応する構成が要るなら、Action を軸とする NgRx Store が向く。監査ログ、開発者ツールでの状態遷移の追跡、複数機能をまたぐ同期がこれにあたる。

逆に、状態の変更手段がメソッド呼び出しで足りるなら、SignalStore の withMethods で十分なことが多い。Action、Reducer、Effect の3層を維持する保守コストに見合う理由があるかどうかで判断する。

Comment
@Reviewer: この機能で必要なのは状態の更新だけで、変更理由を追う要件は見当たりません。Action層を置く判断の背景を共有してもらえますか。

状態の共有範囲と寿命をどこで決めるか

これは4系統のどれを選んでも必ず問われる。Signal を持つ Service を作っても、それだけでアプリ全体で単一の状態になるわけではない。共有範囲と寿命を決めるのは provider をどこに置いたかであって、Service にしたという事実ではないからだ。

コンポーネントの providers に置けば、その画面を離れたときに状態は破棄される。root に置けばアプリの生存期間にわたって残り続ける。どちらが正しいかは要件で決まるが、意図せず選ばれている場合がある。

Comment
@Reviewer: このstoreはコンポーネントの `providers` に登録されているので、画面遷移のたびに状態が初期化されます。検索条件を戻ってきたときに復元したい要件があれば、provider の位置を見直す必要があります。

系統をまたぐ実装

判断軸を通すと、ひとつの機能が複数系統にまたがることがはっきりする。先ほどの検索を SignalStore で組むと、次のようになる。

責務ごとに系統を割り当てた実装
export const UserSearchStore = signalStore(
  withState({ keyword: '', users: [] as User[] }),

  withComputed(({ users }) => ({
    hitCount: computed(() => users().length),
  })),

  withMethods((store, api = inject(UserApi)) => ({
    search: rxMethod<string>(
      pipe(
        debounceTime(300),
        distinctUntilChanged(),
        switchMap(keyword => api.search(keyword)),
        tap(users => patchState(store, { users })),
      ),
    ),
  })),
);

構造化と現在値の保持は SignalStore、派生値は computed()、非同期の競合制御は RxJS が担っている。どれかがもう一方を置き換えているのではなく、担当が分かれている。

なお、この例はエラー処理を省いている。rxMethod は未処理のエラーが起きるとそのパイプライン自体が終了し、以後の呼び出しに反応しなくなるため、実装では内部で捕捉する必要がある。この点は SignalStore を扱う回であらためて取り上げる。

レビュー観点チェックリスト

状態管理の系統を確認する項目
  • 現在値だけが必要な箇所に、通知を扱う仕組みを持ち込んでいないか
  • 並行しうる非同期処理について、重なったときの扱いが選ばれているか
  • Action層を置く判断に、変更理由を追跡する要件が伴っているか
  • 状態の共有範囲と寿命が、provider の位置として意図的に決められているか
  • Signal と RxJS の境界が、打ち切りや順序の必要性で分かれているか
  • 系統を統一すること自体が目的になっていないか

おわりに

4系統が並立している状況は、当面変わりそうにない。どれかに統一すれば設計が単純になるという期待は、統一した先で担えない責務が出てきた時点で崩れる。

レビューで見たいのは、書き手がその境界を意識していたかどうかである。Signal で書かれた非同期処理を見つけたとき、打ち切りが不要だと判断した結果なのか、打ち切りという論点に気づいていないのかで、指摘の内容は変わる。前者なら確認で済み、後者なら設計に戻る必要がある。