SignalStore は状態を置く容器ではなく、feature を合成してクラスを作る関数である。signalStore(...) が返すのは Type<...> であり、インスタンスがいくつ存在していつ消えるかは提供位置が決める。「ストアはグローバルな状態置き場である」という前提で書かれたレビュー観点は、この時点で前提から外れている。ここでは @ngrx/signals 22.0.1 を前提に、SignalStore の設計のレビュー観点を100項目へ整理する。

対象は @ngrx/signals 22.0.1 の本体と、サブパスの entities / events / resource / rxjs-interop / testing である。サブパスはこの6つだけで、他の入口は存在しない。素の signal と computed の設計、resource() の status の読み方はAngular 22のSignalと非同期状態をレビューする100観点に分けてある。@ngrx/store の Action と Reducer(NgRx StoreのAction・Reducer・Effectをレビューする100観点)、Operator による競合制御(Angular 22のRxJS運用と購読の寿命をレビューする100観点)も本リストの範囲には入れていない。

各群は「群の問い」から始まる。差分を読みながらその問いに yes か no で答えられるかを確かめ、答えられない項目をレビューコメントへ変換する、という順で使う想定である。

22.0 で変わったのは2点である。union 型の state スライスについて、オブジェクトリテラルの構成要素それぞれに DeepSignal が作られるようになった(以前は union 全体が1つの Signal に潰れていた)。もう1点は @ngrx/signals/resource というサブパスが増えたことで、こちらは experimental である。

変更とは別に、API の名前そのものを取り違えやすい箇所がある。先に挙げておく。

言われがちな名前・前提 22.0.1 の事実
副作用の feature は withEffects withEventHandlers。withEffects という API は存在しない
signalStoreFeature と withFeature は同じもの withFeature はストアのインスタンスを受け取る。signalStoreFeature は型だけを受ける
withEntities は配列のラッパー state に加わるのは ids と entityMap の2つ。entities は派生
Dispatcher はアプリに1つ 既定は providedIn: 'platform'。provideDispatcher() でスコープを切れる
イベントは全ストアに届く 届く範囲は scope('self' | 'parent' | 'global')が決める
protectedState はテストの邪魔になる @ngrx/signals/testing の unprotected がその対として用意されている

✅ ストアの境界と提供位置(01〜10)

このストアのインスタンスがいくつ存在し、いつ消えるかを差分から言えるかを問う。

SignalStore をグローバルな状態置き場と見ると、この群はほぼ指摘できなくなる。提供位置を1行変えれば、同じストアが画面ローカルにもアプリ全体にもなる。寿命の違いは型にも出ないため、コンパイルでは検出されない。

03 が崩れる筋道は DI の解決規則にある。コンポーネントの providers に置いたストアは、そのコンポーネントの injector に属する。兄弟は別の injector を持つので、兄弟側の注入は自分のインスタンスを作るか、より上位の provider へ遡る。注入は成功して型も合うため、画面上は「片方で更新したのに他方が変わらない」という形でしか現れない。

05 は overload の形に由来する。signalStore の設定を受け取る overload は { providedIn?: 'root' | 'platform'; protectedState?: true } を取り、providedIn を省くと、どこかで provide しなければ解決できないクラスになる。providedIn を書き忘れたストアは実行時の注入エラーまで気付けない。


✅ withState と状態の形(11〜20)

状態の形が、読み手が欲しい粒度の signal を生むかを問う。

state のネストは自由に深くしてよい、とは言えない。SignalStore は state の各プロパティに signal を作り、オブジェクトリテラルのスライスは DeepSignal になって子プロパティごとに読み取れる。state の形がそのまま、購読できる単位の形になる。

14 が 22.0 の破壊的変更に当たる。21 までは union 型のスライスが1つの Signal<A | B> に潰れており、子プロパティは読めなかった。22.0 では構成要素のうちオブジェクトリテラルのものそれぞれに DeepSignal が作られる。非オブジェクトが混ざる構成要素は通常の Signal のままなので、union の中身によって読み取り方が変わる。21 の書き方のまま更新したコードは、型エラーとして現れる。

20 の落とし穴は参照の共有である。const initialState = { filters: {} } を複数のストアが withState(initialState) で受けると、ネストしたオブジェクトは同じ参照を指す。patchState が新しいオブジェクトを作る限り問題にならないが、初期値を破壊的に書き換える処理が1箇所でもあれば、無関係なストアの初期状態まで変わる。


✅ withComputed と派生(21〜30)

派生が state の形から一意に決まり、書き込みの経路を持たないかを問う。

computed は state の一部として扱える、という理解はこの群で最も多い誤りである。withComputed が返すのは読み取り専用の signal であり、書きたくなった時点で、その値は派生ではなく状態である。

27 が制約になるのは、feature が合成の時点で型を受け渡すためである。withComputed の引数には、直前までの feature が生んだ state signals・props・methods だけが渡る。宣言順より後ろの computed は引数に含まれず、参照しようとすれば型エラーになる。順序の入れ替えで直るため実行時の不具合にはならないが、feature の並び替えが型エラーを生む理由はここにある。

28 は公開型の計算に根拠がある。_ 接頭辞のメンバーは OmitPrivate によってストアの公開型から外れる。命名規約ではなく型の規則なので、接頭辞を付け忘れた内部用の派生はコンポーネントから読めてしまい、後から隠すとコンポーネント側が壊れる。

30 は名前の付け方の問題として現れる。showSpinner のような画面都合の名前を付けると、同じ状態を別の画面が別の名前で持ち直すことになる。isLoading のように状態の意味で名付ければ、表示の判断はテンプレート側に残る。


✅ withMethods と更新の経路(31〜40)

状態の更新が、名前のついた操作として1箇所に集まっているかを問う。

patchState はどこから呼んでもよい、と読める書き方がある。protectedState を外したストアはコンポーネントからも patchState できるので、更新の起点を探すには全参照を辿ることになる。

32 が中間状態を見せる理屈は、patchState が渡された部分状態をまとめて適用する点にある。1回の呼び出しでは items と total が同時に新しくなるが、2回に分ければ1回目の直後に items だけが新しい状態ができる。その間に computed が読まれれば、新しい items と古い total の組が画面に出る。更新の単位をメソッド1つにまとめても、patchState の呼び出しが分かれていれば同じことが起きる。

39 は書き戻しの再帰が問題になる。watchState は state の変化のたびに watcher を呼ぶので、その中で patchState を呼ぶと次の変化が生まれる。終了条件が watcher の中の条件分岐にしか書かれていない構造は、state の形を1つ変えるだけで止まらなくなる。派生が目的なら withComputed、source の変化によるリセットが目的なら withLinkedState に移せる。

40 が追いにくさを生むのは、更新の起点がストアの外に出る点にある。ストア A のメソッドがストア B を注入して patchState すると、B の状態の変化理由は B のコードには書かれていない。イベントを介した連携は第7群で扱う。メソッドをどこまで持たせるかの線引きはSignalStoreのメソッド設計で扱っている。


✅ feature の合成(41〜50)

再利用したい塊が、feature として正しい入出力型を持っているかを問う。

signalStoreFeature と withFeature は同じものである、と読める説明がある。違いは受け取るものにある。前者は入力の型だけを受け、後者はストアのインスタンスを受け取る。

43 が使い分けの境目である。signalStoreFeature は入力の型を宣言して合成するだけなので、ストア固有のメソッドの実体には触れない。そのメソッドを呼ぶ feature を書こうとすると、入力型にメソッドの署名を書き足すことになり、feature を使う側のストアすべてがその署名に縛られる。withFeature はストアのインスタンス(state signals・props・methods と WritableStateSource)を受け取る factory を取るので、インスタンスのメソッドを呼ぶ feature はこちらで書ける。

47 は提供位置との組み合わせで結果が変わる。onInit はストアのインスタンスが作られたときに走る。root 提供なら最初の注入で1回だけ、コンポーネント提供なら再マウントのたびに走る。初期取得を onInit に置いたまま提供位置を root へ移すと、2回目以降の表示で取得が起きなくなる。逆向きの変更では取得が毎回走る。どちらも型は通る。


✅ withEntities とコレクション(51〜60)

コレクションの識別子と更新単位が、@ngrx/signals/entities の契約どおりかを問う。

entity は配列の便利なラッパーである、と考えると並び順の扱いを落とす。withEntities が state に加えるのは ids: EntityId[] と entityMap: Record<EntityId, Entity> の2つで、entities はそこからの派生である。並び順を持っているのは ids の側である。

52 を落とすと、キーの重複が静かに起きる。selectId の既定は entity.id であり、id を持たない型でも entityConfig を書かなければキーは undefined になる。entityMap のキーが1つに潰れ、ids は1件だけを保つ。型は通り、画面には最後に入れた1件しか出ない。

53 と 59 は同じ state の形から出ている。collection を指定したコレクションの state は ${collection}Ids と ${collection}EntityMap という名前になる。entityMap を直接読むコードは、コレクション名を変えた時点で参照先の名前が変わって壊れる。entities の派生を使っていれば、名前の対応は withEntities の側に閉じる。

60 の確認が要るのは、更新操作ごとに ids の扱いが違うためである。addEntity は末尾へ、prependEntity は先頭へ id を足す。updateEntity は entityMap の値だけを差し替えるので順序は動かない。setAllEntities は ids を渡した配列の順に作り直す。並び順をサーバーの応答順に合わせたい一覧で upsertEntities を使うと、既存分の位置は保たれ、新規分だけが末尾に付く。


✅ events プラグインとスコープ(61〜70)

何が起きたかと、状態をどう変えるかが分かれているかを問う。

events は NgRx Store の Action を SignalStore に持ち込んだものである、という理解では scope が落ちる。Action はストア全体に届くものだったが、イベントが届く範囲は dispatch 側の指定で決まる。

64 を型で止めることはできない。withEventHandlers の factory が受け取るストアには WritableStateSource が含まれるため、handler の中の patchState はコンパイルを通る。分けておく利点は実行順序の側にある。dispatch されたイベントは ReducerEvents へ同期的に流れ、Events へは queueScheduler を経て流れる。withReducer の遷移が先に終わってから handler が走るので、handler は確定した state を読める。handler 側でも state を書くと、この前後関係が崩れる。

68 は scope の実装に根拠がある。Dispatcher.dispatch は、親の Dispatcher があり scope が 'parent' か 'global' のときだけ親へ渡す。親が無ければ自分のところで流す。provideDispatcher() を置いていないコンポーネントで scope: 'parent' を指定しても、エラーにはならず自分の階層で処理される。'parent' は1段だけ上がる指定で、'global' は親が無くなるまで遡る指定である。

67 の選択を間違えたときの壊れ方は、過不足の両方向に出る。既定の Dispatcher は providedIn: 'platform' の単一インスタンスなので、provideDispatcher() を置かない構成ではすべてのイベントが全ストアに届く。スコープを切った後で 'global' を付け続けると、切った意味が消える。events プラグインの使い方はSignalStoreのeventsプラグインに個別の解説がある。


✅ rxMethod と RxJS 連携(71〜80)

rxMethod の購読が誰の寿命に紐づいているかを、差分から言えるかを問う。

rxMethod は Observable を返す関数である、という理解は結果の型から外れている。返るのは呼び出せる関数であり、内部に Subject と、その購読を1つ保持している。

74 がこの群で最も静かに壊れる。rxMethod は生成時に1度だけ generator(source$) を購読する。エラーが generator のパイプラインを抜けると、この1つの購読が終了する。source$ に購読者がいなくなるので、以後の呼び出しは例外も警告も出さずに捨てられる。1度通信が失敗した画面で、ボタンを押しても何も起きない状態がこれである。75 が対になっていて、tapResponse と mapResponse はエラーを内側の Observable で処理し、外へ流さない。

71 と 72 の根拠は購読の登録先である。rxMethod は生成時の injector の DestroyRef に購読の解除を登録する。withMethods の中で作れば登録先はストアの injector になり、ストアの破棄で購読が切れる。注入コンテキストの外で injector も渡さずに作ると、開発ビルドでは assertInInjectionContext が落ちる。寿命とエラーの扱いはrxMethodの購読の寿命とエラーで個別に扱っている。

80 は呼び出し側の制約である。signal や Observable を引数に渡す呼び出しは、その値の監視をどの injector に紐づけるかを決める必要がある。注入コンテキストの外から injector なしで呼ぶと、22.0.1 では非推奨の警告が出て、呼び出し元の injector の代わりにストア生成時の injector が使われる。コンポーネント側の破棄で監視が止まる前提のコードは、この経路に入ると監視が残り続ける。


✅ resource 拡張と非同期(81〜90)

Resource の挙動をどこで変えたかを追えるかを問う。

extendResource は Resource を包んだ新しいオブジェクトを作る、とは言えない。元の Resource と同じものを返し、指定した振る舞いだけを差し替える。

85 と 86 は1つの機構から出ている。extendResource は RESOURCE_EXTENSIONS を注入して、provide 済みの拡張と引数で渡した拡張を並べ、type をキーにした Map で重複を除いてから順に適用する。読み込み中の値を差し替える拡張は LOADING_EXTENSION_TYPE、失敗時の値を差し替える拡張は ERROR_EXTENSION_TYPE という同じ type を共有する。したがって withPreviousValueOnLoading() と withValueOnLoading(x) を併用すると、後ろに書いたほうだけが適用される。provide 済みの拡張は引数より前に並ぶため、同じ分類をローカルで渡せば、そのローカル指定がアプリ全体の既定を上書きする。

87 は 85 の裏返しである。extendResource が inject(RESOURCE_EXTENSIONS, { optional: true }) を呼ぶので、注入コンテキストの外では inject 自体が落ちる。フィールド初期化子かコンストラクタで作る形になる。

88 と 89 は写した先が古くなる点で同じである。Resource は status と value を自分で持っており、patchState でストア側へ写すと、同じ事実が2箇所に存在する。写す処理を呼ばない経路(reload() や params の変化による再取得)が1つでもあれば、そこから先はストア側の値だけが古い。status の6値の読み方はAngular 22のSignalと非同期状態をレビューする100観点の第6群にある。


✅ テストと検証(91〜100)

ストアの契約が、内部実装に踏み込まずにテストされているかを問う。

protectedState があるとテストしづらい、という話にはならない。@ngrx/signals/testing の unprotected がこのために用意されている。

91 と 92 が対になる理由は型の扱いにある。既定の protectedState: true のストアは、外部からの patchState を型レベルで拒む。unprotected(store) はその制約を外した参照を返すだけなので、本番コードに書けば保護は無くなる。import 元が @ngrx/signals/testing であることを手掛かりに、テスト以外からの参照を禁じる lint 規則を置ける。

96 を通しで確かめる必要があるのは、dispatch から state の変化までが同期ではないためである。ReducerEvents はイベントを同期的に受けるが、Events は queueScheduler を経る。reducer の結果だけを見るテストは dispatch の直後に通り、handler の副作用まで見るテストは1段遅れる。両方を1つのテストで確かめるなら、スケジューラの処理が終わる機会を与えてから検証する。

100 の仮定は、テストの書き方では表に出ない。TestBed.configureTestingModule({ providers: [MyStore] }) で provide したストアはテストごとに新しく作られるが、providedIn: 'root' のストアはテスト用の injector の寿命で決まる。提供位置を変えたときに落ちるテストは、どちらの寿命を前提にしていたかをコメントではなくテスト名で示しておくと、落ちた理由が読める。


範囲の切れ目

@ngrx/store の Action・Reducer・Effect の設計と、RxJS の購読の寿命および Operator による競合制御は、ここに含めていない。4系統はそれぞれ別の API 契約を持ち、1本に押し込めば1系統あたり十数項目にしかならないためである。@ngrx/store はNgRx StoreのAction・Reducer・Effectをレビューする100観点、RxJS はAngular 22のRxJS運用と購読の寿命をレビューする100観点にある。素の signal と Resource の読み取り契約はAngular 22のSignalと非同期状態をレビューする100観点、コンポーネントとテンプレートはAngular 22のコンポーネント設計をレビュー視点で整理する100観点にある。個別論点の解説はng状態管理にまとめてある。