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 を返すだけである。入力の宣言は型検査のためだけに在り、実行時には何も起きない。

typeの実装
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 はここを埋める。

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 のファクトリも他と同じく、その時点までのメンバーだけを受け取る。

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

featureの合成を確かめる項目
  • 複数のストアで重複している 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 の合成」の群に並べてある。