NgRx SignalStoreのdeep signalをunion型stateで読む設計とレビュー観点

withState に渡したオブジェクトは、プロパティごとに読める。ネストしていても同じで、子のプロパティにも () が付く。

入れ子の state をプロパティごとに読む
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 である。

@ngrx/signals 22.0.1 の toDeepSignal
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 で包んで返す。

レコードかどうかは値から決まる。

22.0.1 の isRecord(抜粋)
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 の派生の再計算回数: 1

patchState でスライス全体を置き換えても、o.a の computed は同じ値を返すので版が上がらず、それに依存する派生は走らない。読み出しの粒度は保たれる。

型の側は別の経路で決まる。DeepSignalOf<T> は IsKnownRecord が真のときだけ子のプロパティを並べ、IsUnknownRecord(キーが列挙できない型、つまり添字シグネチャを持つ型)を除外する。Record<string, Filter> を置いたスライスは、型の上では Signal<Record<string, Filter>> のままで子が出ない。実行時の Proxy はキーがあれば子を作るので、ここは型のほうが狭い。

union型のスライスで子が消える経路

画面の状態を判別可能な union で持つ形は、SignalStore でも書ける。

variant ごとに持つキーが違う state
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 をフィールドへ置いた場合である。

22.0.1 で確かめた出力
1) 現在の variant の子: function true
2) 読み出し: [ 1, 2 ]
3) variant 切替後、同じ参照で読む: undefined
4) 新たに参照: undefined
5) 存在する子: a1
6) in チェック: true false

3行目は、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 で確かめる形なら、型と実行時の判断が揃う。

Comment
@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 が返る。捕まえておいた子を読むと、結果が変わる。

選択を null に戻したあとの読み出し
7) null 後の子で例外: TypeError Cannot read properties of null (reading 'id')
8) null 後に親を辿る: [Function: getter] ... null

computed(() => target()['id']) が null['id'] を評価するためである。undefined を取りうるスライスなら同じ式が undefined.id になり、こちらも TypeError になる。

同じ値を Proxy 経由で読めば例外にならない(8行目)。例外になるかどうかが、子 signal を取っておいたかどうかで変わる。レビューでは、null を取りうるスライスの子 signal がフィールドやローカル変数に代入されていないかを見る。初期値に null を入れている state は、型の上でも union になっているので、前節と同じ判断に戻る。

配列やDateを置いたスライス

レコードでない値のスライスでは、get が target[prop] を返す。target は signal の関数そのものなので、関数が持つプロパティが見える。

配列・Date・クラスインスタンスのスライス
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 は呼ばれるたびに新しく作られる。

同じ子を2回読んだとき
10) 同じ子を2回取ると同一か: false

読み出した値を Object.is で比べる箇所では、毎回別物として扱われる。子 signal をそのままコンポーネントの入力へ渡す書き方([source]="store.page.index")は、変更検知のたびに別の参照が渡るため、入力の更新が毎回起きる。@for の track に子 signal を使う形も同じ理由で噛み合わない。

渡すなら値を渡す([index]="store.page.index()")か、computed で1つ作って保持する。signal そのものを子へ渡す設計が要るなら、withComputed で作った派生を使う。こちらは computed が1つなので参照が安定する。

Comment
@Reviewer: `[pageSignal]="store.page.index"` は、deep signal の子を参照ごとに新しい Proxy で包むため、変更検知のたびに別の値が入力へ流れます。値だけが要るなら `store.page.index()` を渡し、signal を渡す必要があるなら `withComputed` で作った派生を渡してください。

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

deep signal の読み出しを確かめる項目
  • 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の使い分けで扱った。