Angularの状態管理4系統をレビューで選び分ける判断軸
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 が担っていた責務ごと消せると考えてしまう場合にある。
@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 の側である。
@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 をやめたわけではない。keyword も users も Signal のままで、テンプレートからの読み方は変わらない。変わったのは、検索という「重なりうる非同期処理」を RxJS の担当へ戻した点だけである。
@Reviewer: 検索のように後続の入力が先行リクエストを無効にする処理は、Signalだけでは順序を保証できません。`toObservable` で通知に変換し、`switchMap` で打ち切ってから `toSignal` で戻す形を検討してください。選択を分ける4つの問い
系統の割り当ては、次の4つを順に問えばおおむね決まる。
値がひとつなのか、通知が続くのか
いま何であるかだけを知りたいなら Signal の領域になる。フォームの入力値、開閉フラグ、選択中のタブは、過去にどう変わったかを保持する必要がない。
一方、値が時間とともに何度も届き、その届き方(間隔、順序、重なり)自体を扱う必要があるなら RxJS になる。入力のたびに走る検索、WebSocket の受信、ポーリングがこれにあたる。
@Reviewer: ここで扱いたいのは現在値だけに見えます。`BehaviorSubject` を使う理由が購読の共有以外にあれば教えてください。なければ `signal()` のほうが読みやすくなります。非同期が重なったとき何を捨てるか
非同期処理が並行しうるなら、重なったときの扱いを決めなければならない。前を打ち切るのか、順番に処理するのか、実行中の後続を無視するのか、並行させるのか。switchMap / concatMap / exhaustMap / mergeMap は、この問いへの4通りの答えである。
選択が要件と合っていないコードは、検索の例と同じ理由で、負荷が低いうちは正しく見える。レビューで確認しておかないと、本番の通信遅延ではじめて表面化する。
@Reviewer: 保存処理に `mergeMap` を使っているため、ボタンの連打でリクエストが多重に飛びます。実行中は後続を捨てる意図なら `exhaustMap`、順に処理する意図なら `concatMap` が合います。状態が変わった理由を残す必要があるか
「何が起きて状態が変わったのか」を記録し、ひとつの出来事に複数の箇所が反応する構成が要るなら、Action を軸とする NgRx Store が向く。監査ログ、開発者ツールでの状態遷移の追跡、複数機能をまたぐ同期がこれにあたる。
逆に、状態の変更手段がメソッド呼び出しで足りるなら、SignalStore の withMethods で十分なことが多い。Action、Reducer、Effect の3層を維持する保守コストに見合う理由があるかどうかで判断する。
@Reviewer: この機能で必要なのは状態の更新だけで、変更理由を追う要件は見当たりません。Action層を置く判断の背景を共有してもらえますか。状態の共有範囲と寿命をどこで決めるか
これは4系統のどれを選んでも必ず問われる。Signal を持つ Service を作っても、それだけでアプリ全体で単一の状態になるわけではない。共有範囲と寿命を決めるのは provider をどこに置いたかであって、Service にしたという事実ではないからだ。
コンポーネントの providers に置けば、その画面を離れたときに状態は破棄される。root に置けばアプリの生存期間にわたって残り続ける。どちらが正しいかは要件で決まるが、意図せず選ばれている場合がある。
@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 で書かれた非同期処理を見つけたとき、打ち切りが不要だと判断した結果なのか、打ち切りという論点に気づいていないのかで、指摘の内容は変わる。前者なら確認で済み、後者なら設計に戻る必要がある。