NgRxのconcatLatestFromでActionが消える実装をレビューで見抜く

Effect の中で Store の状態を参照するとき、concatLatestFromwithLatestFrom より使い勝手がよい。参照先を関数で渡すため、Action が来るまで Selector を評価しない。

その遅延評価と引き換えに、参照先へ強い条件が付く。参照先が同期で値を出さなければ、その Action は後段へ流れない。エラーにもならず、ログにも残らない。

参照先が同期でemitするかどうか

concatLatestFrom は、Action が到着した時点で関数を評価し、返された Observable を購読する。その購読で同期的に値が出た場合だけ、Action と組にして後段へ渡す。

RxJS の挙動として確かめると差は明確になる。Action を2件流したとき、参照先が同期で値を出す場合と出さない場合で次のようになる。

参照先が同期emit(BehaviorSubject)  -> 後段へ流れた数: 2
参照先が非同期emit(delay 10ms)      -> 後段へ流れた数: 0

非同期の参照先を渡すと、Action は1件も通らない。待ってから処理されるのではなく、捨てられる。

理由は実装から説明できる。concatLatestFrom は内部で of(action).pipe(withLatestFrom(...)) という形を取る。of(action) は同期で1つ値を出してすぐ complete するため、その瞬間に参照先の最新値が無ければ、組にする相手がいないまま終わる。

Actionが消えるEffect
saveDraft$ = createEffect(() =>
  this.actions$.pipe(
    ofType(DraftActions.saveRequested),
    concatLatestFrom(() => toObservable(this.editorState)),
@Reviewer
`toObservable` は effect を経由するため、購読した瞬間には値を出しません。初回のActionはここで組にできず、後段へ流れずに捨てられます。
switchMap(([, state]) => this.api.saveDraft(state)), ), );

このコードは、2回目以降は動くことがある。toObservable の内部は ReplaySubject(1) なので、一度値が流れた後であれば購読時に同期で再生されるためだ。「初回だけ保存されない」「リロード直後だけ動かない」という再現条件になり、原因にたどり着きにくい。

同期で値を出さない参照先には、HTTP、delay を挟んだ Observable、初回通知前の toObservable(signal) などがある。通常の Store Selector は購読時に現在値を同期で出すため、この用途に合う。

Comment
@Reviewer: この参照先は購読した時点では同期で値を出しません。`concatLatestFrom` は待たないため、このActionはここで黙って捨てられます。Selector を参照するか、`switchMap` の内側で取得してください。

非同期の値が必要な場合

参照したいものが非同期でしか手に入らないなら、concatLatestFrom の役割ではない。switchMap などの内側で取得し、必要なら結果を Action と組にする。

非同期の値は内側で取得する
saveDraft$ = createEffect(() =>
  this.actions$.pipe(
    ofType(DraftActions.saveRequested),
    switchMap(action =>
      this.editorState.load().pipe(
        switchMap(state => this.api.saveDraft(state)),
        map(saved => DraftActions.saveSucceeded({ id: action.id, saved })),
        catchError(({ message }) => of(DraftActions.saveFailed({ error: message }))),
      ),
    ),
  ),
);

Signal を参照したい場合は、Observable へ変換せずに読む方法もある。Effect の中で Signal を直接読めば、その時点の現在値が同期で得られる。変換を挟むことで生じている問題なので、変換をやめれば消える。

withLatestFrom との違い

withLatestFrom にも似た性質があるが、失われ方が違う。

withLatestFrom は、参照先が少なくとも1回 emit するまで source の通知を後段へ出さない。その間に来た source の値は保留されず、そのまま失われる。

実際に確かめると次のようになる。参照先が値を出す前に source を2件流し、その後で参照先を emit させ、さらに source を1件流した場合。

-> 流れた数: 1  ["after","ref-ready"]   (before-1, before-2 は失われた)

両者の違いは、参照先を購読する回数にある。withLatestFrom は最初に1回購読して以後の最新値を保持する。concatLatestFrom は Action のたびに関数を評価して購読し直す。

遅延評価が必要なら concatLatestFrom、参照先を購読し続けてよいなら withLatestFrom になる。どちらを選んでも「待ってくれない」点は変わらない。

import元の移動

concatLatestFrom は、NgRx 18 で @ngrx/effects からの export が削除されている。現在は @ngrx/operators から取る。

import元
import { concatLatestFrom } from '@ngrx/operators';

バージョンを上げた際に import が残っていると解決できなくなるため、移行の取りこぼしとして見つかることがある。同じパッケージには tapResponsemapResponse も入っている。

Comment
@Reviewer: import 元が `@ngrx/effects` のままです。v18 で `@ngrx/operators` へ移動しています。

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

concatLatestFromを見たときの確認項目
  • 参照先は購読した瞬間に同期で値を出すか
  • toObservable(signal) を参照先に渡していないか
  • HTTP や delay を挟んだ Observable を参照先に渡していないか
  • 「初回だけ動かない」という症状が報告されていないか
  • 非同期の値が必要な箇所を、switchMap の内側へ移せないか
  • import 元が @ngrx/operators になっているか

おわりに

concatLatestFrom で Action が消える不具合は、失敗の痕跡が残らない。例外も出ず、失敗を示す Action も流れず、ただ処理されない。

確認する点はひとつに絞れる。参照先を購読した瞬間に値が出るかどうかである。Store の Selector なら出る。それ以外を渡しているなら、その場で確かめる価値がある。