AngularでBehaviorSubjectの状態をsignalへ移すかをレビューで判断する

サービスに状態を持たせる書き方として、BehaviorSubject を private に置き、asObservable() で読み取り側へ渡す形は長く使われてきた。

BehaviorSubject で持っている選択状態
@Service()
export class SelectionStore {
  private readonly selectedId$ = new BehaviorSubject<string | null>(null);
  readonly selected$ = this.selectedId$.asObservable();

  select(id: string | null): void {
    this.selectedId$.next(id);
  }
}

これを signal へ移す差分は、行の対応がきれいに付く。

signal へ移した同じ状態
@Service()
export class SelectionStore {
  private readonly selectedId = signal<string | null>(null);
  readonly selected = this.selectedId.asReadonly();

  select(id: string | null): void {
    this.selectedId.set(id);
  }
}

差分の見た目どおりに等価なら、レビューは「テンプレートの | が消えたか」を確かめるだけで済む。等価ではない。rxjs 7.8.2 と @angular/core 22.2.1 の実装を読むと、配る回数、終端の有無、配布中の順序、外から書き換えられるかの4点で挙動が分かれる。どちらを選ぶかは、置いてある状態がこの4点のどれに依存しているかで決まる。

系統の選び分けそのものはAngularの状態管理4系統をレビューで選び分ける判断軸にまとめた。ここでは BehaviorSubject と signal の2つに絞り、置き換えの可否を決める機構を並べる。

同じ値を2回流したときに届く回数

Subject.next には比較が無い。

rxjs 7.8.2 の Subject.next
next(value: T) {
  errorContext(() => {
    this._throwIfClosed();
    if (!this.isStopped) {
      if (!this.currentObservers) {
        this.currentObservers = Array.from(this.observers);
      }
      for (const observer of this.currentObservers) {
        observer.next(value);
      }
    }
  });
}

BehaviorSubject.next はこれに this._value = value の代入を足しただけである。同じ値を2回 next すれば、購読側のハンドラは2回走る。

signal の書き込みは比較を通る。

@angular/core 22.2.1 の signalSetFn
function signalSetFn(node, newValue) {
  if (!producerUpdatesAllowed()) {
    throwInvalidWriteToSignalError(node);
  }
  if (!node.equal(node.value, newValue)) {
    node.value = newValue;
    signalValueChanged(node);
  }
}

node.equal の既定は defaultEquals、つまり Object.is である。同じ値の set は版も上がらず、依存している computed も effect も走らない。

この差は、状態が「いま何であるか」を表しているだけなら表面化しない。表面化するのは、同じ値の更新を1回の出来事として数えている場合である。再読み込みの要求を BehaviorSubject<Filter> に同じ条件で流して再取得の契機にしている実装は、signal へ移した時点で2回目以降が消える。

橋渡しだけ入れても同じで、toSignal の内部の signal にも比較が入る。

@angular/core 22.2.1 の makeToSignalEqual
function makeToSignalEqual(userEquality = Object.is) {
  return (a, b) => a.kind === 1 && b.kind === 1 && userEquality(a.value, b.value);
}

BehaviorSubject を残して toSignal で読む構成でも、同値の next は読み手に届かない。回数を保ったまま signal 側へ渡す経路は無いので、回数に意味がある通知は Observable のまま扱うか、更新の連番を値に含めて別の値にする。

Comment
@Reviewer: この `filter$` は同じ条件でも `next` して再取得の契機にしていますが、`signal` へ移すと `Object.is` で同値の `set` が落ちるため、2回目以降の再取得が起きません。再取得の要求は状態ではなく出来事なので、`signal` ではなく Observable か、`reload()` のようなメソッド呼び出しで表すほうが意図に合います。

終端のある入れ物と無い入れ物

Subject は error と complete で終端に達し、以後の next は配られない。BehaviorSubject の読み出しは、終端の種類によって結果が3通りに分かれる。

rxjs 7.8.2 の BehaviorSubject.getValue
getValue(): T {
  const { hasError, thrownError, _value } = this;
  if (hasError) {
    throw thrownError;
  }
  this._throwIfClosed();
  return _value;
}

error() を通ったあとの getValue()(value ゲッターも同じ経路)は、保持している値ではなく渡したエラーを投げる。unsubscribe() を呼んだあとは _throwIfClosed() が ObjectUnsubscribedError を投げる。一方 complete() のあとは closed が立たないため、getValue() は最後の値を返し続ける。

この3通りのうち、complete() のあとの挙動だけは購読側と読み出し側で見える値が分かれる。BehaviorSubject.next は super.next((this._value = value)) という形で、代入のほうが先に済む。

終端ごとの挙動を rxjs 7.8.2 で確かめた出力
complete後のnext: 例外なし, getValue= b
complete後の購読で受けた値= none
error後のgetValue例外= boom
unsubscribe後のgetValue例外= ObjectUnsubscribedErrorImpl object unsubscribed

1行目と2行目が示すのは、完了済みの BehaviorSubject へ next('b') を呼ぶと、購読者には何も配られないまま getValue() が 'b' を返す状態になることである。isStopped の判定は代入の後ろにあるので、配布だけが止まる。サービスの破棄時に complete() を呼び、その後も next が残っている構成では、画面は更新されないのに内部の値だけが進む。

signal には終端が無い。破棄済みの注入器から作った signal でも読み書きはでき、止まるのは effect の実行だけである。したがって complete() を購読の終了の合図として使っていた設計は、移した先では別の仕掛けに置き換える必要がある。エラーを状態として持つ場合は、値の型に成功と失敗を含める形になる。toSignal で橋渡しした場合のエラーの現れ方はAngularのtoObservableをイベント列として使う実装をレビューで止めるで扱った。

配布の途中で next を呼んだときの順序

購読者のハンドラの中から next を呼ぶと、配布の順序が購読者ごとに食い違う。A が v1 を受けた時点で次の値を流す構成で確かめる。

再入を起こす購読
const s = new BehaviorSubject('v0');
s.subscribe(v => { log.push(['A', v]); if (v === 'v1') s.next('v2'); });
s.subscribe(v => log.push(['B', v]));
s.next('v1');
記録された配布順
[["A","v0"],["B","v0"],["A","v1"],["A","v2"],["B","v2"],["B","v1"]]

B に届くのは v2 が先で、v1 が後である。入れ子の next が入った時点で currentObservers は既に作られているため、内側の呼び出しは同じ配列を先頭から回して全員に v2 を配る。外側のループはそのあと中断なく続き、残っていた B へ v1 を配る。購読者ごとに見える履歴の順序が入れ替わり、最後に書いた値が最後に届くとは限らない。

signal で同じ構造を書いても、読み手が読むのは常に最新の値である。書き込みの途中で別の書き込みが起きても、値は1つしかないので順序は問題にならない。effect から中間状態が見えないことはサービスに置いたsignalの共有範囲を提供位置からレビューするで確かめた。

再入が起きるのは、next のハンドラの中で同じ Subject を更新する実装である。状態の正規化(「選択が消えたら先頭を選ぶ」など)を購読側に書くと成立する。この形が入っているコードは、signal と computed へ移すと順序の問題そのものが消える。移さない場合は、正規化を next の呼び出し元へ寄せる。

Comment
@Reviewer: `selected$` の購読の中で `selectedId$.next()` を呼んでいます。rxjs の `Subject` は配布中の再入でも配列を回し直すため、この購読より後ろで購読している箇所には新しい値が先に、元の値が後から届きます。正規化は `select()` の中で済ませるか、`computed` で派生させる形に寄せてください。

外から書き換えられるかどうかの境界

asObservable() は読み取り専用の窓として説明されることが多い。実装は窓を作らない。

rxjs 7.8.2 の Subject.asObservable
asObservable(): Observable<T> {
  const observable: any = new Observable<T>();
  observable.source = this;
  return observable;
}

返るのは source に自分自身を入れた Observable である。Observable.prototype._subscribe が this.source?.subscribe(subscriber) を呼ぶので、購読は元の Subject へ素通りする。そして source は非推奨ではあるが public なプロパティなので、受け取った側から (selected$ as any).source.next(...) で書き込める。型の上では閉じているが、実行時には閉じていない。

signal の asReadonly() も同じ性質を持ち、set と update を外した参照を返すだけで元のノードを共有する。どちらを選んでも、書き込みの制約は型検査にしか存在しない。このシリーズでは protectedState、signalStoreFeature の type()、entityConfig と同じ型だけの境界が繰り返し出ている。レビューでは、公開された読み取り専用の値を信頼の根拠にせず、書き込みの呼び出し元が所有者クラスの中にあるかを直接確かめる。

signal へ移さずに残す判断

4つの差を踏まえると、置き換えてよいのは次の条件を満たす状態である。

  • 読み手が必要とするのは現在値だけで、更新の回数や順序を数えていない
  • 同じ値の更新を区別する必要が無い
  • complete() や error() を通知の手段として使っていない
  • 時間系の制御(間引き、打ち切り、再試行)が購読側に無い

逆に、残す理由がはっきりしているのは次の2つである。

1つは競合制御である。重なった非同期処理のどれを捨てるかは switchMap などで表すもので、signal 側に対応する仕組みが無い。高階 Operator の選び分けはRxJSのswitchMapが実際には何を止めないのかをレビューで確認するにある。

もう1つはイベント列である。同じ値が続くことに意味がある通知、順序を保ちたい通知は Subject のほうが素直に書ける。

両方が要る場合でも、BehaviorSubject を signal と toObservable の組へ移す必要は無い。現在値を signal で持ち、そこから toObservable でイベント列を作ると、変換の数だけ挙動の差が増える。公開する形を signal に寄せるのは、現在値だけを配る場合に限る。

橋渡しを入れる場合、BehaviorSubject には1つ有利な点がある。購読の瞬間に現在値を同期で配るため、toSignal の requireSync が成立する。

rxjs 7.8.2 の BehaviorSubject._subscribe
protected _subscribe(subscriber: Subscriber<T>): Subscription {
  const subscription = super._subscribe(subscriber);
  !subscription.closed && subscriber.next(this._value);
  return subscription;
}

toSignal は購読の直後に state().kind === 0 を見て、同期で値が来ていなければ NG0601 を投げる。BehaviorSubject ならこの検査を通るので、initialValue を足して型に undefined を混ぜる必要が無い。素の Subject や Observable を requireSync: true で渡している箇所は、起動時に例外になる。

Comment
@Reviewer: `toSignal(this.store.state$, { requireSync: true })` の `state$` は `Subject` から作られているため、購読時に値が同期で来ません。初回の読み出しで NG0601 になります。`BehaviorSubject` へ変えるか、`initialValue` を渡す形に変えてください。

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

BehaviorSubject と signal の選択を確かめる項目
  • BehaviorSubject を使う理由が、現在値の保持以外にあるか説明できるか
  • 同じ値を next して契機にしている箇所が無いか(signal では落ちる)
  • signal へ移した差分で、購読側に書いてあった間引きや打ち切りが消えていないか
  • complete() を通知の終了として使っている箇所が、移した先で何に置き換わるか決まっているか
  • サービスの破棄時に complete() を呼んだ後、next が呼ばれる経路が残っていないか
  • value / getValue() の呼び出しが、error() を通ったあとに例外になる前提で書かれているか
  • 購読のハンドラの中から同じ Subject を更新している箇所が無いか
  • asObservable() で渡した値を、書き込み不可の根拠として扱っていないか
  • next の呼び出し元が所有者クラスの中に収まっているか
  • requireSync: true を渡した toSignal の source が、購読時に同期で値を配るか
  • 現在値と出来事の両方を持つサービスで、変換(toSignal / toObservable)が1方向に収まっているか

おわりに

BehaviorSubject と signal は、どちらも初期値を持ち、いつでも現在値を読める。読み出しの形が似ているので置き換えの差分も小さく見えるが、更新の扱いは同じではない。Subject.next は比較せずに配り、signalSetFn は Object.is で落とす。前者には error と complete という終端があり、後者には無い。前者は配布の途中の再入で購読者ごとの順序が崩れ、後者は値が1つなので崩れない。

置き換えの判断は、状態がこの差のどれに依存しているかで決まる。現在値だけを配っているなら移してよく、更新の回数や順序、終端を使っているなら、移す前にその用途を別の形で表す必要がある。差分の行数が合うことは、等価であることの根拠にならない。

公開の形については、asObservable() も asReadonly() も型の上の境界でしかない。どちらを選んでも、書き込みを所有者の中に閉じているかはレビューで確かめることになる。

Subject と状態共有の観点の全体はAngular 22のRxJS運用と購読の寿命をレビューする100観点の「Subject と状態共有」の群に並べてある。