SignalStoreのwithMethodsがAction層の代わりに失うもの

SignalStore は NgRx が提供している。そのため「NgRx の新しい書き方」として紹介されることがあるが、Action、Reducer、Effect の3層を前提としない点で設計が違う。

withMethodspatchState があれば、状態はメソッドの中から直接変更できる。書く量は減る。減ったぶん何が無くなったのかを押さえておくと、採用の判断が説明できるようになる。

メソッド駆動で書いた場合

画面ローカルの検索条件を持つ store を考える。

メソッド駆動のSignalStore
export const ProductSearchStore = signalStore(
  withState({
    keyword: '',
    category: null as CategoryId | null,
    products: [] as Product[],
  }),

  withComputed(({ products }) => ({
    hitCount: computed(() => products().length),
  })),

  withMethods((store, api = inject(ProductApi)) => ({
    setKeyword(keyword: string): void {
      patchState(store, { keyword });
    },
    setCategory(category: CategoryId | null): void {
      patchState(store, { category });
    },
    async search(): Promise<void> {
      const products = await api.search(store.keyword(), store.category());
      patchState(store, { products });
    },
  })),
);

この規模であれば、Action を定義して Reducer で受けて Effect で通信する形より読みやすい。状態の定義、派生値、操作が1か所にまとまっている。

画面ローカルの状態にはこの形が合う。検索条件は他の機能から参照されず、変更の理由を後から追う要件もない。

メソッド駆動で残らない情報

同じ store を複数の画面で共有し、状態がいつ何で変わったのかを追いたくなった場合を考える。

Action 駆動であれば、流れた Action の並びがそのまま記録になる。[Product Page] Keyword Changed[Product API] Search Succeeded が順に並べば、利用者の操作と通信の結果が区別できる。

メソッド駆動では、patchState が呼ばれた事実しか残らない。どのメソッドから呼ばれたのかは、呼び出し元をたどらなければ分からない。メソッドが別のメソッドを呼んでいれば、さらに一段たどることになる。

この差は、機能が増えたときに現れる。ひとつの操作に対して、通知を出す、履歴を記録する、別の store を更新する、という3つの反応が必要になった場合、メソッド駆動では呼び出しを並べることになる。

反応が増えたメソッド
async search(): Promise<void> {
  const products = await api.search(store.keyword(), store.category());
  patchState(store, { products });

  this.notifier.show(`${products.length}`);
  this.history.record('search', store.keyword());
  this.relatedStore.refresh(products);
@Reviewer
ひとつの操作に3つの反応が続いています。どれかを止めたいときにメソッド本体を編集することになるため、反応する側が増え続ける場合はイベント駆動を検討したほうが読みやすくなります。
}

呼び出しが並んでいること自体は読める。追いにくくなるのは、反応する側の都合でこのメソッドを編集し続けることになる点にある。Action 駆動であれば、反応する側が ofType で選ぶため、発火する側は変更されない。

どちらが優れているという話ではない。記録と分離が要らないなら簡潔さが利益になり、要るなら失った情報は後から復元できない。

Comment
@Reviewer: `withMethods` の中で複数の状態をまとめて更新しています。どの操作でどこが変わるのかがメソッド本体を読まないと分からないため、操作単位に分けられませんか。

providerの位置が決める寿命

SignalStore で見落とされやすいのが、共有範囲と寿命である。Service として定義したことと、アプリ全体で単一の状態になることは別である。

決めているのは provider をどこに置いたかにある。

コンポーネントに提供する
@Component({
  selector: 'app-product-search',
  providers: [ProductSearchStore],
  templateUrl: './product-search.html',
})
export class ProductSearchComponent {
  readonly store = inject(ProductSearchStore);
}

この形なら、画面を離れたときに状態は破棄される。検索条件を持ち越さない画面であれば意図に合う。

一覧から詳細へ遷移して戻ったときに条件を残したい要件であれば、破棄されては困る。root に提供するか、ルートの階層に合わせて提供する位置を選ぶ。

どちらも動くため、レビューでは意図の確認が要る。

Comment
@Reviewer: この store はコンポーネントの `providers` にあるので、画面を離れると検索条件が破棄されます。一覧へ戻ったときに条件を復元したい要件があれば、provider の位置を見直す必要があります。

機能の分け方

signalStore に渡す機能には役割がある。混ぜて書くと、後から読む側が探す場所を決められない。

機能 担当
withState 保持する状態の定義
withComputed 状態から決まる派生値
withMethods 状態を変更する操作と副作用
withLinkedState 派生しつつ手動更新も許す状態
withHooks store の初期化と破棄のタイミングで走る処理

派生値を withMethods の中で計算して patchState している場合は、withComputed へ移せることが多い。状態として持つ必要があるのは、計算で求められないものに限られる。

Comment
@Reviewer: `hitCount` は `products` から計算できるので、状態として持たずに `withComputed` へ移せます。更新の呼び忘れで不整合が起きる余地も無くなります。

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

SignalStoreを見たときの確認項目
  • 変更の理由を記録する要件があるのに、メソッド駆動を選んでいないか
  • ひとつのメソッドに複数の反応が並び、編集が続いていないか
  • provider の位置が、状態の寿命として意図的に選ばれているか
  • 計算で求められる値を、状態として持っていないか
  • withState / withComputed / withMethods の担当が混ざっていないか
  • メソッドが別のメソッドを呼ぶ連鎖が深くなっていないか

おわりに

SignalStore を選ぶ判断は、NgRx を使うかどうかではなく、状態の変更を出来事として記録する必要があるかどうかで決まる。

レビューで確認できるのは、その必要が実際にあるかどうかである。画面ローカルの状態にAction層を敷いていれば過剰だと言えるし、複数箇所が反応する状態をメソッド駆動で持っていれば、いずれ追えなくなると指摘できる。