NgRxのconcatLatestFromでActionが消える実装をレビューで見抜く
NgRxのconcatLatestFromでActionが消える実装をレビューで見抜く
Effect の中で Store の状態を参照するとき、concatLatestFrom は withLatestFrom より使い勝手がよい。参照先を関数で渡すため、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 するため、その瞬間に参照先の最新値が無ければ、組にする相手がいないまま終わる。
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 は購読時に現在値を同期で出すため、この用途に合う。
@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 { concatLatestFrom } from '@ngrx/operators';バージョンを上げた際に import が残っていると解決できなくなるため、移行の取りこぼしとして見つかることがある。同じパッケージには tapResponse と mapResponse も入っている。
@Reviewer: import 元が `@ngrx/effects` のままです。v18 で `@ngrx/operators` へ移動しています。レビュー観点チェックリスト
- 参照先は購読した瞬間に同期で値を出すか
toObservable(signal)を参照先に渡していないか- HTTP や
delayを挟んだ Observable を参照先に渡していないか - 「初回だけ動かない」という症状が報告されていないか
- 非同期の値が必要な箇所を、
switchMapの内側へ移せないか - import 元が
@ngrx/operatorsになっているか
おわりに
concatLatestFrom で Action が消える不具合は、失敗の痕跡が残らない。例外も出ず、失敗を示す Action も流れず、ただ処理されない。
確認する点はひとつに絞れる。参照先を購読した瞬間に値が出るかどうかである。Store の Selector なら出る。それ以外を渡しているなら、その場で確かめる価値がある。