NgRx SignalStoreの提供位置が状態の寿命を決めることをレビューで確かめる

signalStore(...) の戻り値はクラスである。@ngrx/signals 22.0.1 の実装では、組み立てた feature を順に適用したうえで、providedIn の指定をそのまま Angular の injectable 宣言へ渡している。

signalStoreが宣言しているもの
static ɵprov = ɵɵngDeclareInjectable({
  type: SignalStore,
  providedIn: config.providedIn || null,
});

つまり SignalStore の寿命は、SignalStore 固有の仕組みではなく Angular の DI が決める。レビューで読むべき箇所は、withState の形よりも先に、このクラスがどこで提供されているかになる。

提供位置とインスタンスの数

signalStore の第1引数に設定を渡す場合、受けるのは providedIn と protectedState の2つである。providedIn が取る値は 'root' と 'platform' だけで、省略すると上の実装のとおり null になる。

書き方 インスタンス 状態が消えるとき
signalStore({ providedIn: 'root' }, ...) アプリに1つ アプリの破棄時
signalStore({ providedIn: 'platform' }, ...) プラットフォームに1つ プラットフォームの破棄時
設定を省略し、コンポーネントの providers に置く そのコンポーネントのインスタンスごとに1つ そのコンポーネントの破棄時
設定を省略し、ルートの providers に置く そのルートの注入器ごとに1つ ルートから離れたとき
設定を省略し、どこにも置かない 作られない —

最後の行は実行時に失敗する。providedIn: null のクラスは自動で提供されないため、注入しようとすると No provider found for ...(NG0201)になる。これはビルドでは分からず、その画面を開いた時点で初めて出る。

'platform' を選ぶ理由は、同一プラットフォーム上に複数のアプリを起動し、その間で状態を共有する場合に限られる。単一アプリで 'platform' と 'root' の差は観測できないため、理由の説明がないまま 'platform' になっている箇所は 'root' の書き間違いを疑う。

rootに置いた画面ローカルの状態

提供位置の誤りとして出やすいのは、1つの画面でしか使わない状態を root に置く形である。

検索条件をrootのストアに持たせている
export const ProductSearchStore = signalStore(
  { providedIn: 'root' },
  withState({ keyword: '', page: 1, sort: 'name' as SortKey }),
  withMethods((store) => ({
    setKeyword(keyword: string): void {
      patchState(store, { keyword, page: 1 });
    },
  })),
);

この書き方は動く。初回の表示も検索も期待どおりになる。差が出るのは画面を離れて戻ったときで、keyword と page が前回の値のまま残る。root 提供のインスタンスはアプリの生存期間にわたって存在し、ルーティングでは破棄されないためである。

残ってほしい状態なら正しい。残ってほしくないなら、初期化の責務が画面側へ移る。ngOnInit で patchState(store, initialState) を呼ぶ実装がこの形の後始末にあたり、初期値の定義が2箇所に分かれる。

Comment
@Reviewer: この条件は商品検索の画面だけで使うものに見えます。`providedIn: 'root'` のままだと再訪時に前回の検索条件が残るため、意図したものかどうか確かめさせてください。残さない想定であれば、設定を外してルートの `providers` に置くほうが初期化を書かずに済みます。

逆向きの誤りもある。ログイン中の利用者や権限のように、画面をまたいで同じ値を見るべき状態をコンポーネントの providers に置くと、画面ごとに別のインスタンスが立ち、取得も別々に走る。

兄弟が共有すると思っている場合

コンポーネントの providers に置いたストアは、そのコンポーネントのインスタンスごとに作られる。兄弟のコンポーネントがそれぞれ providers に同じストアを書いていれば、同じクラスでも別のインスタンスになる。

兄弟がそれぞれ自分のインスタンスを持つ
@Component({
  selector: 'app-cart-summary',
  providers: [CartStore], // ここで1つ
  // ...
})
export class CartSummary {
  readonly store = inject(CartStore);
}

@Component({
  selector: 'app-cart-items',
  providers: [CartStore], // ここでもう1つ
  // ...
})
export class CartItems {
  readonly store = inject(CartStore);
}

片方で追加した商品が、もう片方の一覧に出ない。型は通り、それぞれのコンポーネントを単体でテストすれば通る。共有させるなら、providers を共通の親かルートの定義へ上げ、子は inject だけにする。

この見分け方は、ストアに限らず Angular の providers 全般と同じである。SignalStore で見落としやすくなるのは、signalStore(...) の呼び出し自体がファイルの先頭にあって、提供位置は別のファイルに書かれているからで、差分が片方しか含まないときに判断材料が揃わない。

Comment
@Reviewer: `CartStore` が2つのコンポーネントの `providers` に入っているため、別インスタンスになります。片方の更新がもう片方に届かない形なので、`providers` はこの2つを含む親側に1つだけ置いてください。

onInitとonDestroyが走る位置

withHooks で渡したフックは、提供位置と組で読む必要がある。実装では、feature を適用し終えた直後のコンストラクタの中で onInit() が同期的に呼ばれ、onDestroy は inject(DestroyRef).onDestroy(...) で登録される。

コンストラクタの中で起きていること
const { onInit, onDestroy } = hooks;
if (onInit) {
  onInit();
}
if (onDestroy) {
  inject(DestroyRef).onDestroy(onDestroy);
}

onInit が走るのはストアが最初に注入された時点であり、それを注入したコンポーネントの ngOnInit より前である。onInit の中で初期データの取得を始めている場合、取得の開始時刻は「最初に誰かが inject した瞬間」になる。root 提供のストアなら、アプリ内のどこか1箇所が最初に注入した時点で1回だけ走る。

onDestroy が登録される DestroyRef は、ストアを作った注入器のものである。root 提供なら破棄はアプリの終了時で、画面の遷移では呼ばれない。onDestroy で購読を止める実装を書いていても、root 提供のままでは実質的に動かない。購読の寿命を注入コンテキストから読む手順はAngularの購読と副作用の寿命を注入コンテキストから読むで扱っている。

Comment
@Reviewer: `withHooks` の `onDestroy` でポーリングを止めていますが、このストアは `providedIn: 'root'` です。登録先の `DestroyRef` がアプリ全体のものになるため、画面を離れても止まりません。

protectedStateが引く境界の性質

設定のもう1つは protectedState である。型定義には protectedState?: true を受けるオーバーロードと protectedState: false を受けるオーバーロードが並んでいて、前者は戻り値を StateSource、後者は WritableStateSource にする。patchState が要求するのは WritableStateSource なので、省略した(あるいは true を書いた)ストアは、外部から patchState を呼ぶと型エラーになる。

ここで押さえておきたいのは、この境界が型だけで引かれていることである。配布されている fesm2022/*.mjs に protectedState という文字列は1つも現れない。withState は常に signal(...) で状態を作るため、実行時の STATE_SOURCE に入っているのはどのストアでも書き込み可能な signal である。

その結果が @ngrx/signals/testing の unprotected の挙動に出る。

unprotectedの実装
function unprotected(source) {
  if (isWritableStateSource(source)) {
    return source;
  }
  throw new Error('@ngrx/signals: The provided source is not writable.');
}

isWritableStateSource は STATE_SOURCE の全キーが set と update を持つかを実行時に確かめる。signalStore の状態は常にそれを満たすので、protectedState を省いたストアに対しても unprotected は例外を投げず、そのまま書き込める値を返す。保護は型検査が止めてくれるという意味であり、実行時の不変条件ではない。

したがって unprotected が本番のコードに現れたら、型エラーの回避として使われたものとして読む。テストのための抜け道が、そのまま実装の抜け道になる。

protectedState: false を選ぶ理由は、外部から patchState する必要がある、という形で説明できなければならない。説明できる場合でも、更新の入口が withMethods の外へ散るため、どこから状態が変わるかを追う手間は増える。withMethods に寄せる場合との得失はSignalStoreのwithMethodsがAction層の代わりに失うものにある。

なお patchState へ初期状態に無いキーを渡した場合、その更新は無視される。開発モードでは patchState was called with an unknown state slice の警告が出るが、本番ビルドでは何も出ない。キー名の綴りを変えるリファクタリングでは、withState 側だけ直して patchState 側が残る経路が成立する。

差分から寿命を読む順序

提供位置は1箇所しか書かれていないため、差分に現れない場合がある。読む順序を決めておくと、必要な情報が差分に無いことに気付ける。

  1. signalStore(...) の第1引数に設定があるか。あれば providedIn の値を見る
  2. 無ければ、このクラスを providers に置いている箇所を探す
  3. 置いている箇所が複数あるなら、それぞれが別インスタンスである前提で読む
  4. withHooks があれば、onInit と onDestroy が2か3で決まった寿命と噛み合うかを見る
  5. 状態の中身に、その寿命を越えて残っては困る値(検索条件、入力中のフォーム、選択状態)が含まれていないかを見る

5 までたどって初めて、withState の形の議論になる。

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

提供位置と寿命を確かめる項目
  • providedIn: 'root' の状態が、画面を離れても保持される前提と合っているか
  • 画面ローカルの状態を root に置き、再訪時の初期化を画面側で補っていないか
  • providedIn: 'platform' を選んだ理由が、複数アプリ間の共有として説明できるか
  • 設定を省いたストアが、注入される経路すべてで provide されているか
  • 同じストアが複数のコンポーネントの providers に入り、共有の前提と食い違っていないか
  • withHooks の onInit が、注入時に走ることを踏まえた内容になっているか
  • onDestroy の登録先の DestroyRef が、止めたい処理の寿命と一致しているか
  • protectedState: false の理由が、外部から patchState する必要として説明できるか
  • unprotected が本番のコードに現れていないか
  • patchState に渡すキーが、すべて初期状態に定義されているか

おわりに

SignalStore を読むとき、目に入るのは withState と withMethods の中身である。だが状態が消えるタイミングを決めているのは、ファイルの先頭にある設定か、別のファイルの providers のどちらかである。この2箇所が差分に含まれていなければ、残りを読んでも寿命は確定しない。

観点の全体はNgRx SignalStoreの状態設計をレビューする100観点の第1群に並べてある。差分に提供位置が無いときは、まずそれを尋ねるところからになる。