NgRx SignalStoreのdeep signalをunion型stateで読む設計とレビュー観点
NgRx SignalStoreのdeep signalをunion型stateで読む設計とレビュー観点
withState に渡したオブジェクトは、プロパティごとに読める。ネストしていても同じで、子のプロパティにも () が付く。
export const PaginationStore = signalStore(
{ providedIn: 'root' },
withState({ page: { index: 0, size: 20 } }),
);
const store = inject(PaginationStore);
store.page(); // { index: 0, size: 20 }
store.page.index(); // 0この store.page.index は、withState の時点では存在しない。@ngrx/signals 22.0.1 では、読んだ瞬間に現在の値を見て作られる。型の上では常にあるように見えるため、値の形が変わるスライス、とくに union 型のスライスでレビューの争点になる。
子signalが作られる時機
実装は Proxy である。
function toDeepSignal(signal) {
return new Proxy(signal, {
has(target, prop) {
return !!this.get(target, prop, undefined);
},
get(target, prop) {
const value = untracked(target);
if (!isRecord(value) || !(prop in value)) {
if (isSignal(target[prop]) && target[prop][DEEP_SIGNAL]) {
delete target[prop];
}
return target[prop];
}
if (!isSignal(target[prop])) {
Object.defineProperty(target, prop, {
value: computed(() => target()[prop]),
configurable: true,
});
target[prop][DEEP_SIGNAL] = true;
}
return toDeepSignal(target[prop]);
},
});
}get は3つの判断をする。まず untracked(target) で現在値を取り、それがレコードでないか、読もうとしたキーを持たないなら、以前に作った子 signal を捨てて target[prop] をそのまま返す。キーを持つなら、子 signal が無ければ computed(() => target()[prop]) を作って target に生やし、その子をまた Proxy で包んで返す。
レコードかどうかは値から決まる。
function isRecord(value) {
if (value === null || typeof value !== 'object' || isIterable(value)) {
return false;
}
let proto = Object.getPrototypeOf(value);
if (proto === Object.prototype) {
return true;
}
while (proto && proto !== Object.prototype) {
if (nonRecords.includes(proto.constructor)) {
return false;
}
proto = Object.getPrototypeOf(proto);
}
return proto === Object.prototype;
}配列(反復可能)、null、Date や Promise などの組み込み、プリミティブはレコードではない。クラスのインスタンスは、プロトタイプ鎖に組み込みが無ければレコードとして扱われる。
子 signal が computed である点には利点がある。同じスライスの別のキーだけが変わったとき、こちらの派生は再計算されない。
11) b だけ変えたとき a の派生の再計算回数: 1patchState でスライス全体を置き換えても、o.a の computed は同じ値を返すので版が上がらず、それに依存する派生は走らない。読み出しの粒度は保たれる。
型の側は別の経路で決まる。DeepSignalOf<T> は IsKnownRecord が真のときだけ子のプロパティを並べ、IsUnknownRecord(キーが列挙できない型、つまり添字シグネチャを持つ型)を除外する。Record<string, Filter> を置いたスライスは、型の上では Signal<Record<string, Filter>> のままで子が出ない。実行時の Proxy はキーがあれば子を作るので、ここは型のほうが狭い。
union型のスライスで子が消える経路
画面の状態を判別可能な union で持つ形は、SignalStore でも書ける。
type View =
| { kind: 'list'; items: readonly number[] }
| { kind: 'detail'; id: string };
const state = signalState<{ view: View }>({
view: { kind: 'list', items: [1, 2] },
});DeepSignalOf<View> は union のまま分配されるので、型は DeepSignal<{kind:'list';items:…}> | DeepSignal<{kind:'detail';id:string}> になる。どちらかに絞らずに state.view.items と書けばコンパイルエラーになり、ここは型が守る。
守らないのは、絞り込んだあとに取り出した子 signal をフィールドへ置いた場合である。
1) 現在の variant の子: function true
2) 読み出し: [ 1, 2 ]
3) variant 切替後、同じ参照で読む: undefined
4) 新たに参照: undefined
5) 存在する子: a1
6) in チェック: true false3行目は、kind: 'list' のときに取っておいた state.view.items を、kind: 'detail' へ切り替えたあとに呼んだ結果である。computed(() => target()['items']) が現在値の items を読むので、例外ではなく undefined が返る。テンプレートでは @for の対象が空になるか、length の読み出しで落ちる。
4行目は、切り替えたあとに Proxy 経由で読み直した結果である。'items' in value が偽になるため、get はキャッシュを消して target['items'] を返す。undefined が返るので、state.view.items() と書けば「関数でない」という TypeError になる。型の上で signal である値が、実行時には signal ですらない。
6行目の in は Proxy の has が現在値を見て答えている。判別のキー(kind)で絞るか、読む直前に in で確かめる形なら、型と実行時の判断が揃う。
@Reviewer: `ngOnInit` で `this.items = this.store.view.items` を取っていますが、`view` が `detail` に変わるとこの signal は `undefined` を返します。子 signal は読んだ時点の値から作られるため、フィールドに置くと variant の切り替えに付いていけません。`store.view()` を `computed` で判別してから読む形に変えてください。変化する形を1つのスライスに入れるなら、読み出しは毎回 Proxy を通す。あるいは state の形を変えて、variant ごとのキーを常に存在させる。どちらを選ぶかは、union で表したい排他性がレビューで共有されているかによる。state の形そのものの選び方はNgRx SignalStoreの状態設計をレビューする100観点の「withState と状態の形」の群にある。
nullを取りうるスライス
null はレコードではないので、子を読み直せば undefined が返る。捕まえておいた子を読むと、結果が変わる。
7) null 後の子で例外: TypeError Cannot read properties of null (reading 'id')
8) null 後に親を辿る: [Function: getter] ... nullcomputed(() => target()['id']) が null['id'] を評価するためである。undefined を取りうるスライスなら同じ式が undefined.id になり、こちらも TypeError になる。
同じ値を Proxy 経由で読めば例外にならない(8行目)。例外になるかどうかが、子 signal を取っておいたかどうかで変わる。レビューでは、null を取りうるスライスの子 signal がフィールドやローカル変数に代入されていないかを見る。初期値に null を入れている state は、型の上でも union になっているので、前節と同じ判断に戻る。
配列やDateを置いたスライス
レコードでない値のスライスでは、get が target[prop] を返す。target は signal の関数そのものなので、関数が持つプロパティが見える。
9) 配列の子: 0 / Date の子: undefined / クラスの子: [Function: computed] { ... [Symbol(DEEP_SIGNAL)]: true }配列のスライスに .length と書くと、返るのは配列の要素数ではなく関数の引数の数、つまり 0 である。Signal<T> は関数型なので length の読み出しは型検査を通り、どのビルドでも警告が出ない。要素数が要るなら state.arr().length と書く。
Date のスライスで .getTime を読むと undefined が返る。こちらは Signal<Date> に getTime が無いため型検査で止まる。
クラスのインスタンスは、isRecord がレコードと判定するため子 signal が作られる。prop in value はプロトタイプのメンバーにも真を返すので、メソッド名でも computed が生える。state にクラスのインスタンスを入れると、読み出しの形が意図から離れていく。入れ物としては素のオブジェクトに揃えるほうが、patchState の更新も素直に書ける。
子signalの参照の同一性
get は最後に toDeepSignal(target[prop]) を返す。下にある computed はキャッシュされるが、包む Proxy は呼ばれるたびに新しく作られる。
10) 同じ子を2回取ると同一か: false読み出した値を Object.is で比べる箇所では、毎回別物として扱われる。子 signal をそのままコンポーネントの入力へ渡す書き方([source]="store.page.index")は、変更検知のたびに別の参照が渡るため、入力の更新が毎回起きる。@for の track に子 signal を使う形も同じ理由で噛み合わない。
渡すなら値を渡す([index]="store.page.index()")か、computed で1つ作って保持する。signal そのものを子へ渡す設計が要るなら、withComputed で作った派生を使う。こちらは computed が1つなので参照が安定する。
@Reviewer: `[pageSignal]="store.page.index"` は、deep signal の子を参照ごとに新しい Proxy で包むため、変更検知のたびに別の値が入力へ流れます。値だけが要るなら `store.page.index()` を渡し、signal を渡す必要があるなら `withComputed` で作った派生を渡してください。レビュー観点チェックリスト
- union 型のスライスを、判別のキーか
inで絞ってから子を読んでいるか - 子 signal をフィールドやローカル変数に代入して使い回していないか
nullやundefinedを取りうるスライスの子 signal を取っておいていないか- 配列のスライスに
.lengthを直接書いていないか(関数の引数の数が返る) Record<string, T>のスライスで、型に出ない子を実行時の挙動として期待していないか- state にクラスのインスタンスや関数が入っていないか
- 子 signal をそのまま入力や
trackへ渡していないか - 子へ signal を渡す必要がある箇所で、
withComputedの派生を使っているか - variant ごとに持つキーが違う state が、本当に排他なのか説明できるか
patchStateでスライス全体を置き換える更新が、子 signal の粒度を壊していないか
おわりに
@ngrx/signals の deep signal は、withState に渡した型からではなく、読んだ時点の値から作られる。レコードでなければ子は作られず、キーが無ければ以前の子は捨てられる。型の上では常に子が並んでいるので、この差は union 型のスライスと null を取りうるスライスで表に出る。
出方は、子 signal をどう持ったかで変わる。Proxy 経由で読み直せば undefined が返り、取っておいた子を読めば undefined か TypeError になる。どちらも型検査では止まらず、警告も出ない。したがって読み出しの書き方そのものがレビューの対象になる。
包む Proxy が毎回新しくなる点は、値を読む限り影響しない。影響するのは参照を渡す場合で、入力の束縛や track のように Object.is で比べる箇所に子 signal を直接置くと、更新が毎回起きる。渡すのは値か、withComputed で作った派生に揃える。
feature の合成と状態の形の関係はNgRx SignalStoreのsignalStoreFeatureとwithFeatureの使い分けで扱った。