NgRx SignalStoreの提供位置が状態の寿命を決めることをレビューで確かめる
NgRx SignalStoreの提供位置が状態の寿命を決めることをレビューで確かめる
signalStore(...) の戻り値はクラスである。@ngrx/signals 22.0.1 の実装では、組み立てた feature を順に適用したうえで、providedIn の指定をそのまま Angular の injectable 宣言へ渡している。
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 に置く形である。
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箇所に分かれる。
@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(...) の呼び出し自体がファイルの先頭にあって、提供位置は別のファイルに書かれているからで、差分が片方しか含まないときに判断材料が揃わない。
@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の購読と副作用の寿命を注入コンテキストから読むで扱っている。
@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 の挙動に出る。
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箇所しか書かれていないため、差分に現れない場合がある。読む順序を決めておくと、必要な情報が差分に無いことに気付ける。
signalStore(...)の第1引数に設定があるか。あればprovidedInの値を見る- 無ければ、このクラスを
providersに置いている箇所を探す - 置いている箇所が複数あるなら、それぞれが別インスタンスである前提で読む
withHooksがあれば、onInitとonDestroyが2か3で決まった寿命と噛み合うかを見る- 状態の中身に、その寿命を越えて残っては困る値(検索条件、入力中のフォーム、選択状態)が含まれていないかを見る
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群に並べてある。差分に提供位置が無いときは、まずそれを尋ねるところからになる。