SignalStoreでEvents pluginへ戻すべき判断基準

SignalStore を選ぶと Action 駆動を捨てることになる、という理解は正確ではない。@ngrx/signals/events を使えば、SignalStore の上でイベント駆動を組める。

メソッド駆動とイベント駆動は、SignalStore を採用した後に選ぶ2つの形である。どちらへ倒すかの基準を持っておくと、後から作り直す範囲を小さくできる。

戻したくなる2つの状況

判断の基準は2つに絞れる。

1つは、状態が変わった理由を記録する必要があるかどうか。監査の要件、利用者ごとの操作履歴、開発者ツールでの追跡がこれにあたる。メソッド駆動では patchState が呼ばれた事実しか残らないため、後から理由を復元できない。

もう1つは、ひとつの出来事に複数の箇所が反応するかどうか。反応が1つのうちはメソッドの中に書いても読める。3つを超えたあたりから、発火する側が反応する側の都合で編集され続ける状態になる。

反応が積み上がったメソッド
async submitOrder(): Promise<void> {
  const order = await api.submit(store.draft());
  patchState(store, { order, draft: null });

  this.cart.clear();
  this.notifier.show('注文を受け付けました');
  this.analytics.track('order_submitted', order.id);
  this.recommendation.refresh(order.items);
}

このメソッドは読める。問題は、推薦の更新を止めたくなったときに submitOrder を編集することになる点にある。注文の送信という処理と、それに反応する処理が同じ場所に置かれている。

イベント駆動にすると、発火する側は出来事を知らせるだけになり、反応する側がそれぞれ購読する。反応を増やしても減らしても、発火する側は変わらない。

export されている名称

ここで実装に入る前に確認したい点がある。@ngrx/signals/events が export しているのは withEventHandlers であり、withEffects ではない。

紛らわしいことに、型定義に付いている使用例のコメントには withEffects が残っている。@ngrx/signals 22.0.1 の型定義を確認すると、使用例の中に4箇所現れるが、ファイル末尾の export 宣言には含まれていない。

export { Dispatcher, Events, ReducerEvents, event, eventGroup, injectDispatch,
         mapToScope, on, provideDispatcher, withEventHandlers, withReducer };

使用例をそのまま写すと、解決できない import を書くことになる。ドキュメントの例が旧名のまま更新されていない状態なので、名称は export 宣言の側で確認する。

役割は2つに分かれている。状態遷移の定義は withReducer、副作用の定義は withEventHandlers が担う。

Comment
@Reviewer: `withEffects` は 22 系の export に含まれていません。使用例のコメントに残っているだけなので、`withEventHandlers` を使ってください。

この食い違いは、公式の情報であっても export と使用例で食い違いうる例として覚えておきたい。名前が解決できなければビルドで気づけるが、レビューの段階で指摘できればやり直しが減る。

出来事と反応を分ける

イベント駆動へ移すと、先ほどのメソッドは出来事の定義と反応の定義に分かれる。

出来事を定義する
export const orderEvents = eventGroup({
  source: 'Order Page',
  events: {
    submitRequested: type<void>(),
  },
});

export const orderApiEvents = eventGroup({
  source: 'Order API',
  events: {
    submitSucceeded: type<{ order: Order }>(),
    submitFailed: type<{ error: string }>(),
  },
});

状態の更新は withReducer で、submitSucceeded に反応させる。通知や履歴の記録は withEventHandlers で、同じ出来事にそれぞれが反応する。反応を1つ止めたいときは、その反応の定義だけを外せばよい。

Action 駆動の NgRx Store と同じ構造だが、状態は Signal のまま扱える。Store を丸ごと入れ替える必要はない。

イベントが届く範囲

既定の Dispatcher は providedIn: 'platform' で提供される。コンポーネントやルートで provideDispatcher() を書かないかぎり、すべてのイベントが同じ1つの Dispatcher を通り、どこからでも購読できる。

階層を作るのは provideDispatcher() である。これを providers に書いたコンポーネントは、自分と子孫だけの Dispatcher と Events を持つ。ここで分かれる挙動が、レビューで見落とされやすい。

階層ごとのDispatcherを用意する
@Component({
  // ...
  providers: [provideDispatcher()],
})
export class OrderPanel { /* ... */ }

イベントは親から子へ流れ、子から親へは流れない。Events の実装は、親の Events があればその通知を自分の通知と merge して購読者へ渡す。子の階層で発火したイベントは子の購読者にしか届かず、親の階層で発火したイベントは親と子の両方に届く。

この向きを越えるために、dispatch は第2引数に EventScopeConfig({ scope: EventScope })を取る。EventScope は3つである。

scope 発火する位置
'self'(省略時) 自分の階層
'parent' 1つ上の階層
'global' 親が無くなるまで遡った最上位

'parent' は1段だけ上げる指定で、'global' は遡れるかぎり上げる指定である。上に Dispatcher が無い場所で 'parent' を指定した場合は例外にならず、自分の階層で発火する。階層を作り忘れていても、作りすぎていても、同じコードが黙って別の範囲で動く。

withEventHandlers の中では、イベント単体の代わりにイベントと toScope(...) の組を返す。ストリーム全体を1つの範囲に寄せるなら mapToScope(...) を使う。

失敗の通知だけを最上位へ上げる
withEventHandlers((_, events = inject(Events), api = inject(OrderApi)) => ({
  submit$: events.on(orderEvents.submitRequested).pipe(
    exhaustMap(() =>
      api.submit().pipe(
        mapResponse({
          next: (order) => orderApiEvents.submitSucceeded({ order }),
          error: (error: { message: string }) => [
            orderApiEvents.submitFailed({ error: error.message }),
            toScope('global'),
          ],
        }),
      ),
    ),
  ),
})),

コンポーネントから発火する場合は injectDispatch を関数として呼ぶ。dispatch.submitRequested() は 'self'、dispatch({ scope: 'global' }).submitRequested() は最上位への発火になる。

範囲の指定を間違えても型は通る。組を返す形と、イベントだけを返す形は、どちらも withEventHandlers が受け付ける。現れる症状は、購読しているはずの処理が動かないことだけである。

Comment
@Reviewer: このコンポーネントは `provideDispatcher()` で階層を作っているため、ここで発火したイベントは親側の購読者に届きません。通知を全体で扱うのであれば `toScope('global')` を添えてください。

派生と手動更新を両立させる

イベント駆動へ移さずに解決できる場合もある。依存元から決まる値でありながら、利用者が上書きできる状態がそれにあたる。

withLinkedState を使うと、派生と手動更新を1つの状態で扱える。

withLinkedStateで両立させる
withLinkedState(({ visibleProducts }) => ({
  selectedProduct: () => visibleProducts()[0] ?? null,
})),

一覧が変われば選択も計算し直され、利用者が選べばその値が保持される。withMethods で同期していた処理を置き換えられることが多い。

依存元が変わっても前の選択を残したい場合は、linkedSignal を返す形にして維持の条件を書く。維持するかどうかを暗黙にせず、条件として明示できる点が要点になる。

反応が増えたわけではなく、派生の表現が足りていなかっただけなら、イベント駆動へ移す前にここを検討する。

4系統の割り当てを振り返る

このシリーズは、Angular の状態管理を4系統に分けるところから始めた。ここまでの記事で扱った不具合は、いずれも系統の担当を取り違えたときに起きている。

  • 非同期の重なりを Signal で扱おうとした
  • 時間軸の合流を Store の状態同士に適用した
  • 購読され続けるストリームを外側の置き換えで終わらせた
  • 現在値を返す契約の参照先に、非同期のものを渡した

ひとつの機能の中で系統が混ざること自体は指摘の理由にならない。混ざり方が要件と噛み合っているかどうかだけが論点になる。SignalStore でイベント駆動を選ぶ判断も、その延長にある。

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

イベント駆動への移行を検討する項目
  • 状態が変わった理由を後から追う要件があるか
  • ひとつの操作に反応する処理が3つを超えていないか
  • 発火する側が、反応する側の都合で編集され続けていないか
  • withEffects を使おうとしていないか。export は withEventHandlers か
  • provideDispatcher() で階層を作ったうえで、イベントが届く範囲と購読する位置が合っているか
  • 'parent' と 'global' の指定が、上に Dispatcher が無い場合も想定した形になっているか
  • 派生の表現不足を、メソッドでの同期で埋めていないか
  • 出来事の定義に、起点が分かる source が付いているか

おわりに

メソッド駆動とイベント駆動は、どちらかが上位にあるわけではない。記録と分離が要るかどうかで決まる。

要らないうちにイベント駆動を敷けば手数が増え、要るようになってから戻せば作り直しになる。レビューで確認したいのは、いま選んでいる形がその機能の要件と合っているかどうかである。合っていないなら、反応が増える前に指摘するほうが安く済む。