NgRx StoreのAction・Reducer・Effectをレビューする100観点
NgRx Store の API は 22 でほとんど動いていない。動いたのは周辺である。Angular 側が OnPush を既定にして zoneless へ寄った結果、
store.select()をasyncパイプで受ける構成とstore.selectSignal()で受ける構成の差が差分の上で見えるようになり、props 付き Selector には v23 での削除予告が付いた。「Store に入れたか」「Effect にcatchErrorを書いたか」で止まるリストは、置き場所の是非とcatchErrorの位置を問えていない。ここでは@ngrx/store22.0.1 を前提に、Store の設計のレビュー観点を100項目へ整理する。
対象は @ngrx/store / @ngrx/effects / @ngrx/entity / @ngrx/operators / @ngrx/router-store / @ngrx/store-devtools のいずれも 22.0.1 と、Angular 22.2.1 の組み合わせである。SignalStore の設計はNgRx SignalStoreの状態設計をレビューする100観点、素の signal と Resource の読み取り契約はAngular 22のSignalと非同期状態をレビューする100観点に分けてある。Operator 単体の契約と購読の寿命も本リストの範囲には入れていない。
各群は「群の問い」から始まる。差分を読みながらその問いに yes か no で答えられるかを確かめ、答えられない項目をレビューコメントへ変換する、という順で使う想定である。
22.0 の変更は、@ngrx/entity の selectId の型推論が string と number のどちらかに解決されるようになった点、@ngrx/store-devtools に actionCreators 設定が加わった点、@ngrx/data と @ngrx/effects と @ngrx/component-store の schematic が inject() を使う生成に変わった点である。いずれも書き方を変えれば済む範囲で、設計の判断を動かすものではない。
設計の判断に関わるのは、変更ではなく前提の取り違えのほうである。先に挙げておく。
| 言われがちな名前・前提 | 22.0.1 の事実 |
|---|---|
createReducer の on は上から順に試され、最初に合致したものが使われる |
該当するすべての on が順に適用される |
createSelector はメモ化するので何度呼んでも安い |
記憶されるのは直前の1件だけ |
props 付き Selector(createSelector(..., (s, props) => ...))は現役の書き方 |
非推奨。v23 で削除される予定 |
@ngrx/operators には競合制御の Operator が揃っている |
公開 API は concatLatestFrom / mapResponse / tapResponse の3つだけ |
catchError を書いてあれば Effect は止まらない |
外側に置くとソースが complete する。既定のハンドラは最大10回まで再購読する |
createEntityAdapter を使えば並び順も決まる |
sortComparer を省くと ids は追加順のまま |
store.dispatch の戻り値は常に void |
signal を読む関数を渡す overload は EffectRef を返す |
StoreModule.forRoot と provideStore はどちらでもよい |
NgModule 版は残っているが、provideState による遅延登録と組み合わせる前提になっていない |
✅ ストアの導入境界(01〜10)
この状態を Store に置く理由が、共有範囲と履歴の必要性で説明できるかを問う。
アプリの状態は全部 Store に入れる、という方針はこの群をすべて無効にする。1画面で閉じる状態を Store に入れると、Action と Reducer の往復が増えるだけで、共有も履歴も得られない。
04 が設計上の判断になるのは、provideState が実行時に reducer を登録する関数だからである。遅延ルートの providers で呼べば、そのルートへ入るまで state は存在しない。provideStore({ ... }) にすべての機能 reducer を並べると、どのルートからも使われない state が起動時から存在し、初期化のタイミングを選べなくなる。
08 の判断が runtime checks と連動する点は第9群で扱う。Date やクラスインスタンスを state に置くと strictStateSerializability が落ちるため、置くか検査を切るかのどちらかを選ぶことになる。選んだ側を差分から読めるかが 08 の問いである。
10 を書面で要求する理由は、両者が同じ状態を持てることにある。Store と SignalStore はどちらも「共有された状態」を名乗れるので、境界が書かれていないと、同じ値が2箇所で更新される構成が後から足される。Angularの状態管理4系統をレビューで選び分ける判断軸に、4系統の選び分けをまとめてある。
✅ Action 設計(11〜20)
Action が「起きたこと」を表し、発行元が一意に決まるかを問う。
Action を状態更新の命令として設計すると、同じ更新に対して発行元ごとの Action が増える。更新したい state の形が Action 名に現れるため、state の形を変えるたびに Action 名も変わる。
14 の筋道は配信の仕組みにある。dispatch された Action は全 Reducer と全 Effect に配られる。1つの Action を複数の購読者が拾う設計それ自体は意図的でありうるが、Reducer の適用と Effect の発火の間に順序の保証はない。Effect の中で「Reducer が state を更新し終えている」ことを当てにした store.select() の読み取りは、この保証のない順序に依存する。第5群の 47 と合わせて見る。
11 と 13 が同じ誤解から出る点は、Action を命令と見たときの帰結として説明できる。命令なら発行元が何であれ同じ命令を送ればよいので、setFilter 1つで済む。出来事として設計すると、検索欄からの変更と URL 復元からの変更は別の出来事になり、Effect がどちらに反応するかを選べる。NgRx Actionを命令ではなく出来事として設計させるレビューに、起点を兼ねた Action が多重反応を生む経路を書いてある。
18 を型で要求できない理由は、HttpErrorResponse が表示可能な文字列を持たないことにある。error プロパティの中身はサーバーの応答次第で、message はブラウザ向けの英文である。state に入れた時点ではコンパイルが通り、画面に出した段階で読めない文字列が出る。
✅ Reducer(21〜30)
Reducer が純粋で、state の遷移が Action 名から読めるかを問う。
createReducer の on は上から順に試される、という理解がこの群の指摘を取りこぼす。該当するすべての on が順に適用されるため、先に書いた on が後続を打ち消すことはない。
同じ Action に対する on を2箇所に書いた場合、両方が走る。1つ目の結果が2つ目の入力になるので、同じプロパティを両方が書けば後に書いたほうが残る。意図して2段にしているのか、片方を書いたことを忘れているのかは、コードの形では区別が付かない。レビューでは Action 名で on を検索して、重複がないかを見る。
23 が混入しやすいのは、スプレッドが1段しかコピーしないためである。{ ...state, filters: state.filters } は filters の参照をそのまま渡すので、受け取った側が filters.keyword = '' と書けば元の state も変わる。strictStateImmutability を有効にしていれば実行時に落ちるが、検査を切った構成では画面の表示だけがずれる。
28 の壊れ方は、取得が2本同時に走る場面で現れる。1本目の完了で loading: false になり、2本目がまだ走っていてもスピナーが消える。取得ごとのキーを持つ辞書か、走っている件数のカウンタにすれば、どちらの表現でも同時実行に耐える。
✅ Selector(31〜40)
Selector が、コンポーネントが欲しい形まで計算を終えているかを問う。
createSelector はメモ化するから何度呼んでも安い、とは言えない。記憶されるのは直前の1件だけで、入力が前回と違う参照になれば射影関数が走る。
32 の帰結は下流に出る。既定のメモ化は入力が前回と同一参照なら前回の結果を返す仕組みなので、射影関数が items.filter(...) の結果を返すと、state が変わっていなくても新しい配列になる。これを入力に取る下流の Selector はメモ化の判定で「変わった」と見なし、async パイプで受けたテンプレートも再描画の対象にする。
33 を今回の版で挙げるのは、@ngrx/store 22.0.1 の型定義に v23 での削除予告が書かれているためである。createSelector(selectFoo, (foo, props) => ...) の形と、store.select(mapFn, props) および select(key1, key2, ...) のキー列挙 overload がまとめて非推奨になっている。移行先は、props を引数に取って Selector を返すファクトリ関数である。ただしファクトリを呼ぶたびに新しい Selector ができるので、34 の問いがここで戻ってくる。
34 が崩れる経路は、呼び出し位置にある。テンプレートの中で selectItemById(item.id) を呼ぶと、描画ごとに新しい Selector が作られ、どれも1件しか記憶を持たないまま捨てられる。コンポーネント側で id ごとに Selector を作り置きするか、辞書を1回の Selector で取ってコンポーネントで引くかのどちらかになる。Store 内の状態同士を組み合わせる場面で Selector の合成ではなく combineLatest を使うと、ここで述べたメモ化そのものを失う。その経路はNgRx SelectorをEffect内のcombineLatestで代用しない理由にまとめてある。
36 の判断材料は、テンプレートがどちらの更新経路に乗るかである。selectSignal は SelectSignalOptions の equal を受け取れるので、参照が変わっても等価と見なす比較を与えられる。select を async パイプで受ける形にこの余地はなく、32 の新しい参照がそのまま描画につながる。
✅ Effects(41〜50)
Effect のストリームが、エラーで止まらない形になっているかを問う。
catchError は Effect の最後に付ければよい、という置き方がこの群で最も多い誤りである。外側に付けるとエラーがソースまで伝わり、以後の Action を拾わなくなる。
41 の機構は、既定のエラーハンドラの実装から読める。defaultEffectsErrorHandler は catchError でエラーを受け、ErrorHandler へ渡したうえで元の Observable を再購読する。再試行の上限は10回で、それを過ぎると最後の1回として元の Observable をそのまま返す。つまり外側に catchError を置いた Effect は「止まる」のではなく、再購読を10回繰り返したあとに素の状態へ戻る。再購読の合間に dispatch された Action は購読者がいないため、取りこぼしが起きる。catchErrorの置き場所がNgRx Effectを停止させる仕組みに、内側と外側で何が変わるかと、差分での見分け方を書いてある。
48 で withLatestFrom を避けるのは、購読の開始時点が違うためである。withLatestFrom はストリームの構築時に引数の Observable を購読するので、Action が来る前から state を読み始める。concatLatestFrom は concatMap の中でファクトリを呼ぶため、Action が届いてから購読が始まる。ただしこの遅延には条件が付く。実装は of(value).pipe(withLatestFrom(...observables)) であり、of は購読と同時に値を出す。参照先が同期で値を返す Observable(store.select() はこれに当たる)なら組になるが、HTTP のように後から値が来る Observable を渡すと、組にならないまま of の emit が終わり、その Action は失敗 Action も出さずに消える。concatLatestFromでActionが消える条件に、同期で emit しない参照先を渡した場合の挙動を書いてある。
49 を混在の有無として問う理由は、エラー時の扱いが同じではないことにある。どちらの形も EFFECTS_ERROR_HANDLER を通るが、クラス形式では1つのクラスに複数の Effect が入るため、どの Effect が再購読されたのかがログから読み取りにくい。形式を選ぶ判断そのものは自由で、1つのコードベースで揃っているかを見る。
✅ 競合制御とライフサイクル(51〜60)
同じ Action が連続したときに何が起きるかが、Operator の選択から読めるかを問う。
非同期は全部 switchMap で書いておけば安全である、という方針は保存処理で破れる。switchMap は新しい値が来た時点で内側の購読を解除するので、送信中のリクエストが中断される。
52 が検出しにくいのは、中断が成功として見えることにある。switchMap による解除は内側の Observable の購読解除であり、エラーにはならない。保存の Action が2回続けば1回目の HTTP が中断され、失敗 Action も成功 Action も出ないまま終わる。画面には何も表示されず、サーバー側に書き込みが届いているかは実装次第である。switchMap の解除が実処理まで止めるかは内側の実装次第で、この切り分けはswitchMapが実際には何を止めないのかにまとめてある。
57 の前提は ROOT_EFFECTS_INIT の発行位置である。この Action はルートの Effect が登録された時点で1回だけ流れる。遅延ルートで provideEffects を呼んだ機能の Effect は、ルート起動後に登録されるため、この Action を受け取れない。遅延読み込みする機能の初期取得は、ROOT_EFFECTS_INIT ではなく ROUTER_NAVIGATED か OnInitEffects を起点にする。
60 の位置の違いは、何に対する間引きかを決める。ofType の直後に debounceTime を置けば、連続した Action のうち最後だけが残る。switchMap の内側に置けば、内側のストリームごとに待ち時間が入り、Action 自体は全部通る。入力欄の検索では前者、1件ごとの保存のリトライ間隔では後者になる。
✅ @ngrx/entity(61〜70)
コレクションの識別子・並び順・部分更新が adapter の契約どおりかを問う。
adapter を使えば並び順も決まる、という理解はここで崩れる。sortComparer を指定しなければ ids は追加順のままで、並べ替えは呼び出し側に残る。
62 が 22 の変更に当たる。IdSelector<T> は IdSelectorStr<T> と IdSelectorNum<T> の union で、createEntityAdapter の overload が selectId の戻り値の型を見てどちらかに解決する。21 までは ids の型が string[] | number[] のまま残り、呼び出し側で絞り込みが必要だった。selectId: (x) => x.id の id が string | number の union だと今もどちらにも解決されないので、モデル側の型を片方に寄せるかが 62 の問いである。
65 の違いは、state に残るものに出る。setAll は既存のエンティティをすべて捨てて渡した配列で置き換える。upsertMany は渡したものを追加または更新し、渡していないものを残す。ページングされた一覧で setAll を使うと前のページが消え、upsertMany を使うと全ページ分が積み上がる。どちらが正しいかは一覧の意味次第で、取り違えると「戻ると件数が増えている」形で現れる。
69 を adapter が面倒を見ない理由は、EntityState が ids と entities しか持たないことにある。別のスライスに置いた選択中の ID や展開中の ID は、removeOne の対象外である。削除後に残った ID で entities[id] を引くと undefined が返り、テンプレート側の ?. で静かに空欄になる。
✅ Router Store と Signal 連携(71〜80)
URL が状態の一部になっているか、それとも二重管理になっているかを問う。
router-store を入れればルーティングの状態管理ができる、とはならない。入れただけでは遷移が Action として流れるようになるだけで、URL を唯一の真実にする設計は別に要る。
73 の選択肢は3つある。MinimalRouterStateSerializer は遷移 ID と URL に加えてルートのスナップショットを削った形を state に入れ、FullRouterStateSerializer は Angular のルーターのスナップショットをそのまま入れる。後者はコンポーネントの型やルーターの内部オブジェクトを含むため、strictStateSerializability と両立しない。自作の RouterStateSerializer を置く構成では、画面が使うパラメータだけに絞れる。navigationActionTiming を PostActivation にすれば、ガードの解決後に Action が流れる。
77 の破棄が要るのは、この overload が effect を張るためである。store.dispatch(() => someAction({ id: id() })) の形で関数を渡すと、読んだ signal の変化に応じて Action を発行する effect ができ、戻り値は EffectRef になる。注入コンテキストの中で呼べば呼び出し元の破棄に合わせて止まるが、外から呼ぶ場合は injector を渡す必要があり、不要になった時点で destroy() を呼ぶ経路がなければ Action が流れ続ける。
78 を混在として問う理由は、更新の契機が別になることにある。selectSignal の読み取りは signal のグラフに乗り、async パイプは購読の emit で markForCheck を呼ぶ。どちらでも画面は更新されるが、同じテンプレートに両方あると、再描画の原因を追うときに2つの経路を同時に見ることになる。80 で自前の変換を疑う根拠も同じところにある。Store の値を toObservable や toSignal で往復させる実装は、signal 側の安定化期間で値が間引かれる。toObservableをイベント列として使う実装をレビューで止めるに、連続更新のうち最後の値だけが流れる理由を書いてある。
✅ 運用(81〜90)
本番とデバッグで、Store の構成が意図どおりに切り替わるかを問う。
runtime checks は開発用の補助である、という扱いでは 83 を判断できない。本番で切るかどうかは、state の不変性をどこで保証するかの設計判断である。
83 の検査は6種類ある。strictStateImmutability と strictActionImmutability が凍結による書き換えの検出、strictStateSerializability と strictActionSerializability がシリアライズできない値の検出、strictActionWithinNgZone が NgZone 外からの dispatch の検出で、strictActionTypeUniqueness は任意指定である。zoneless 構成では strictActionWithinNgZone を有効にする意味がなくなるので、ここだけ切るのは理由のある無効化になる。残りを切る場合は、不変性をレビューと規約で保証することを引き受けたという意味になる。
85 と 87 が同じ経路を見ている点は、meta-reducer の位置から説明できる。meta-reducer はすべての Action を通るので、ここで localStorage へ書けば Action ごとに書き込みが走る。保存対象を絞らなければ認証トークンや一時的な UI 状態まで永続化され、次回起動時に復元される。
86 が保証を要求するのは、復元した値が reducer の型検査を通らないままストアへ入るからである。前のバージョンで保存した state を今の reducer が受け取ると、プロパティが欠けていても undefined として読み進む。保存側にバージョン番号を持たせ、一致しないときは初期 state へ戻す経路があるかを見る。
✅ テストと検証(91〜100)
Reducer・Selector・Effect が、それぞれ独立して検証されているかを問う。
MockStore を使えば Store のテストになる、とはならない。MockStore が差し替えるのは Selector の戻り値であり、Reducer は1行も走らない。
93 の漏れが静かに通る理由は、overrideSelector が次の emit を起こさないことにある。差し替えた値は Selector の呼び出し時には返るが、既に購読しているテンプレートへは流れない。refreshState() を呼ぶと state が更新されたものとして emit が起き、購読側が新しい値を受け取る。呼び忘れたテストは、初回の値だけを見て通る。
95 を失敗系の必須項目にする根拠は、第5群の 41 と同じ実装にある。catchError の位置を外側に書いた Effect は、1回目のエラーでは既定のハンドラが再購読するため、1回しかエラーを流さないテストでは通ってしまう。エラーのあとに別の Action を流し、それを拾えることまで確かめると位置の誤りが落ちる。
98 の線引きは、何を検証するかで決まる。provideMockStore は Selector の戻り値を固定するので、コンポーネントが受け取った値をどう描くかのテストに向く。Action の発行から state の更新までを見るテストでは、実際の reducer を登録した Store が要る。両方を provideMockStore で書くと、dispatch したことの確認だけが残り、state が正しく遷移するかは誰も見ていない状態になる。
SignalStore の feature 合成、素の signal と Resource の読み取り契約、RxJS の購読の寿命と Operator 単体の契約は、ここに含めていない。4系統はそれぞれ別の API 契約を持ち、1本に押し込めば1系統あたり十数項目にしかならないためである。SignalStore はNgRx SignalStoreの状態設計をレビューする100観点、signal と Resource はAngular 22のSignalと非同期状態をレビューする100観点、コンポーネントとテンプレートはAngular 22のコンポーネント設計をレビュー視点で整理する100観点にある。個別論点の解説はng状態管理にまとめてある。