NgRx SignalStoreのsignalStoreFeatureとwithFeatureの使い分け
NgRx SignalStoreのsignalStoreFeatureとwithFeatureの使い分け
同じ withState と withMethods の組が3つのストアに現れたら、feature に切り出す判断になる。@ngrx/signals には似た名前が2つあり、signalStoreFeature で束ねる形と、withFeature でストアを受け取る形がある。どちらでも動くように見える場面があり、差分では選んだ理由が残らない。
2つの違いは、feature を作る関数が何を受け取るかにある。型の宣言だけを受け取るのか、組み立て途中のストアの実体を受け取るのか。合成の仕組みを読むと、どちらを選ぶかは好みではなく、その feature が何に依存しているかで決まる。
featureは左からの畳み込み
@ngrx/signals 22.0.1 の signalStore は、feature を順に適用してストアを作る。
class SignalStore {
constructor() {
const innerStore = features.reduce((store, feature) => feature(store), getInitialInnerStore());
const { stateSignals, props, methods, hooks } = innerStore;
// インスタンスへ書き写し、onInit を呼ぶ
}
}1つの feature は、内部のストアを受け取って新しい内部のストアを返す関数である。内部のストアは4つの入れ物と状態の実体を持つ。
function getInitialInnerStore() {
return { [STATE_SOURCE]: {}, stateSignals: {}, props: {}, methods: {}, hooks: {} };
}signalStoreFeature は、この畳み込みを入れ子にしただけである。
function signalStoreFeature(...args) {
const features = (typeof args[0] === 'function' ? args : args.slice(1));
return inputStore => features.reduce((store, feature) => feature(store), inputStore);
}第1引数が関数でなければ捨てられる。捨てられるのは type<{...}>() の戻り値で、この関数は undefined を返すだけである。入力の宣言は型検査のためだけに在り、実行時には何も起きない。
function type() {
return undefined;
}したがって signalStoreFeature(type<{ state: { id: number } }>(), withMethods(...)) と書いたときに保証されるのは、id を持つストアに対してだけこの feature を適用できるという型の制約である。実行時に id の有無を確かめる処理は入らない。型だけの境界という点は、protectedState と同じ性質である。提供位置と状態の寿命についてはNgRx SignalStoreの提供位置が状態の寿命を決めることをレビューで確かめるで扱った。
並び順が満たすべき条件
畳み込みなので、各 feature が見られるのは自分より前の feature が積んだものだけである。withMethods のファクトリへ渡される引数を見ると、その時点までの3つの入れ物を平らにした物であることが分かる。
const methods = methodsFactory({
[STATE_SOURCE]: store[STATE_SOURCE],
...store.stateSignals,
...store.props,
...store.methods,
});この物はその場で作られるので、後ろの feature が足すメンバーは入っていない。withMethods の中で後ろの withComputed が定義する signal を読む実装は、型検査で落ちる。型を回避しても、その場に無いメンバーを呼ぶ例外になる。並び替えで解決する場合、動くようになったのは依存の順序が揃ったからであって、並びに意味が生まれたわけではない。レビューでは、並びが「後続が型を満たす最小の順序」になっているかを見る。withState を最後に移しても動くなら、その feature は状態に依存していない。
withHooks の onInit は特別な位置にある。signalStore のコンストラクターの中で同期的に呼ばれるため、すべての feature が積み終わったストアを見られる。初期取得を onInit に置いた実装が並びの制約を受けないのはこのためで、並びの議論は withComputed と withMethods の間だけに当てはまる。
ストアのメソッドに依存するfeature
signalStoreFeature で書けないのは、ストア固有のメソッドの実体を必要とする feature である。型で { methods: { loadById: ... } } と宣言すれば適用の条件は表せるが、再利用する側はその名前のメソッドを持つことを強制される。別のストアで fetchUser という名前を使っていれば適用できない。
withFeature はここを埋める。
function withFeature(featureFactory) {
return store => {
const storeForFactory = {
[STATE_SOURCE]: store[STATE_SOURCE],
...store.stateSignals,
...store.props,
...store.methods,
};
return featureFactory(storeForFactory)(store);
};
}受け取るのは withMethods のファクトリと同じ形の物、つまりその時点までに積まれたメンバーの実体である。featureFactory はそれを見て feature を作り、作った feature が改めてストアへ適用される。feature の側は引数で読み込み関数を受け取る形になり、メソッドの名前に縛られない。
export function withEntityLoader<T>(load: (id: number) => Promise<T>) {
return signalStoreFeature(
withState<{ entity: T | undefined; isLoading: boolean }>({ entity: undefined, isLoading: false }),
withMethods(store => ({
async loadEntity(id: number): Promise<void> {
patchState(store, { isLoading: true });
patchState(store, { entity: await load(id), isLoading: false });
},
})),
);
}
export const UserStore = signalStore(
withMethods(() => ({ loadById: (id: number) => fetchUser(id) })),
withFeature(store => withEntityLoader(store.loadById)),
);withFeature が渡すのは store.loadById の実体であり、withEntityLoader は引数として受け取る。使い分けの基準はここにある。状態の形だけに依存する feature は signalStoreFeature と type で書き、ストアが持つ関数の実体を使う feature は withFeature で渡す。前者は適用の条件を型で表し、後者は依存を引数で表す。
型の側には SignalStoreFeatureType という補助がある。signalStoreFeature で作った feature の出力を、別の feature の入力の宣言として使い回せる。同じ形を type<{ state: ... }>() に手で書き写した差分は、feature の定義が変わったときに追従しない。
名前の衝突が黙って通る条件
同じ名前のメンバーを2つの feature が定義した場合、警告は出るが止まらない。
function assertUniqueStoreMembers(store, newMemberKeys) {
const storeMembers = { ...store.stateSignals, ...store.props, ...store.methods };
const overriddenKeys = Reflect.ownKeys(storeMembers).filter(key => newMemberKeys.includes(key));
if (overriddenKeys.length > 0) {
console.warn('@ngrx/signals: SignalStore members cannot be overridden.', 'Trying to override:',
overriddenKeys.map(key => String(key)).join(', '));
}
}呼び出し側は ngDevMode で囲われている。本番のビルドでは検査そのものが走らず、衝突は黙って通る。そして警告の文面は上書きできないと読めるが、実際には後から積んだ側が勝つ。withMethods の戻りは methods: { ...store.methods, ...methods } で、同じキーは置き換わる。
種類をまたいだ衝突はもう1段ややこしい。インスタンスへ書き写すときの並びが決まっているためである。
const storeMembers = { ...stateSignals, ...props, ...methods };状態のキーと同じ名前のメソッドを別の feature が定義した場合、store.total はメソッドになる。一方で状態は STATE_SOURCE に残り続けるので、patchState(store, { total: 10 }) は通り、getState(store).total は 10 を返す。読み出しだけが別の物を指す状態になる。feature を切り出すときに名前の衝突を避ける手段として、@ngrx/signals は _ で始まる名前を公開の型から外す仕組みを持っている。
type OmitPrivate<T> = {
[K in keyof T as K extends `_${string}` ? never : K]: T[K];
};外れるのは型だけで、実体はインスタンスに在る。feature の内部だけで使う状態やメソッドに _ を付けておけば、ストアを注入した側の補完には出てこない。ただし衝突の検査は _ の付いた名前も対象なので、2つの feature が同じ _cache を持てば同じ警告が出る。汎用の feature を公開する場合は、接頭辞を feature の名前に合わせる。
設定を引数に取るか状態に持つか
feature を関数にしたとき、引数で受けるか状態として持つかの判断が出てくる。引数は feature を作る時点で固定され、状態は後から変えられる。
export function withPaging(pageSize: number) {
return signalStoreFeature(
withState({ page: 1 }),
withComputed(({ page }) => ({ offset: computed(() => (page() - 1) * pageSize) })),
);
}pageSize を実行時に変える要件があるなら、引数ではなく withState に置く。逆に変わらない値を状態へ入れると、patchState で書き換えられる余地が残り、getState の結果にも混ざる。withProps に置く選択肢もあり、こちらは注入したサービスのような変化しない依存に向く。withProps のファクトリも他と同じく、その時点までのメンバーだけを受け取る。
レビュー観点チェックリスト
- 複数のストアで重複している state と methods の塊が feature になっているか
- 状態の形だけに依存する feature が
signalStoreFeatureとtypeで書かれているか - ストアのメソッドの実体を使う feature が
withFeature経由で受け取っているか - feature の並びが、後続が型を満たす最小の順序になっているか
withMethodsの中で後ろの feature のメンバーを読もうとしていないかtype<{...}>()に手で書いた形が、SignalStoreFeatureTypeで置き換えられないか- 2つの feature が同じ名前のメンバーを定義していないか(本番では警告が出ない)
- 状態のキーとメソッドの名前が衝突していないか(読み出しだけ別物になる)
- feature の内部だけで使うメンバーに
_が付いているか - 変わらない設定値が
withStateではなく引数かwithPropsに置かれているか withHooksのonInitで行う初期取得が、提供位置の寿命と合っているか
おわりに
feature の合成は左からの畳み込みで、各 feature はそれまでに積まれたメンバーだけを見る。並び順の制約も、withMethods から後ろのメンバーが読めないことも、ここから出てくる。signalStoreFeature が束ねるのはこの畳み込みで、type は型の宣言だけを足す。
withFeature を選ぶ基準は、feature がストアの関数の実体を必要とするかである。必要なら withFeature で引数として渡し、feature の側はメソッドの名前に依存しない形に保つ。必要がなければ signalStoreFeature と type で適用の条件を型として書く。どちらで書いても動く場面では、依存の表し方が読む人に伝わるほうを選ぶ。
名前の衝突は本番のビルドで黙って通る。feature を増やすほど、ストアのメンバーの名前空間は共有される。_ の接頭辞は型から隠すだけで衝突そのものは防がないため、公開する feature の名前の付け方は feature を切り出す時点で決めておく。
SignalStore の観点の全体はNgRx SignalStoreの状態設計をレビューする100観点の「feature の合成」の群に並べてある。