catchErrorの置き場所がNgRx Effectを停止させる仕組み
catchErrorの置き場所がNgRx Effectを停止させる仕組み
「一度保存に失敗してから、画面を再読み込みするまで保存が実行されなくなる」という不具合がある。ボタンは押せるし、Actionも発火している。それでもEffectが動かない。エラーログにも何も出ない。
原因が catchError の置き場所であることは、コードを読んでも気づきにくい。catchError は書いてあるからだ。
Effectの購読が続く範囲
前提として、NgRx の Effect は Action のたびに作られるものではない。actions$ を購読し続ける1本のObservableであり、アプリの起動から終了までその購読が維持される。
この前提を踏まえると、次のコードの問題が見えてくる。
saveOrder$ = createEffect(() =>
this.actions$.pipe(
ofType(OrderActions.saveRequested),
switchMap(({ order }) => this.api.save(order)),
map(saved => OrderActions.saveSucceeded({ saved })),
catchError(() => of(OrderActions.saveFailed())),
@Reviewerこの `catchError` は `switchMap` の外側にあります。失敗すると置き換え先の `of(...)` が complete し、Effect のストリーム自体が完了するため、以後 `saveRequested` に反応しなくなります。 ),
);catchError は、上流でエラーが起きたときにストリームを別のObservableへ置き換えるOperatorである。ここで置き換え先に指定されているのは of(OrderActions.saveFailed()) で、これは1つ値を出して complete する。
置き換えが起きるのはストリームの最上流から見た位置、つまり actions$ を含む部分である。置き換え先が complete すれば、Effect本体が complete する。購読し続けるはずの1本が終わってしまう。
実際に同じ構造を組んで、1回目を失敗、2回目と3回目を成功させると次のようになる。
外側に catchError -> [FAILURE_ACTION, COMPLETE]
内側に catchError -> [FAILURE_ACTION, saved-ok-1, saved-ok-2]外側に置いた場合、失敗Actionを1つ出した直後に complete し、その後の2回は処理されない。
@Reviewer: この `catchError` は `switchMap` の外側です。1回失敗するとEffect自体が完了し、以後このActionを処理しなくなります。内側へ移してください。内側へ移すと何が変わるか
直し方は、catchError を内部Observableの側へ移すことに尽きる。
saveOrder$ = createEffect(() =>
this.actions$.pipe(
ofType(OrderActions.saveRequested),
switchMap(({ order }) =>
this.api.save(order).pipe(
map(saved => OrderActions.saveSucceeded({ saved })),
catchError(() => of(OrderActions.saveFailed())),
),
),
),
);内側に置くと、置き換えの対象は this.api.save(order) だけになる。この内部Observableが complete しても、switchMap は次の入力を待ち続ける。actions$ の購読は切れない。
同じことを @ngrx/operators の tapResponse で書くこともできる。成功と失敗の扱いを1か所にまとめられるぶん、内側であることが構造から読み取りやすい。
同じ理由で止まる他のOperator
同じ理由で Effect を止めるOperatorは他にもある。よく見るのは take(1) と、EMPTY への置き換えである。
// 1度だけ処理したいつもりで置いた take(1)
loadOnce$ = createEffect(() =>
this.actions$.pipe(
ofType(ConfigActions.loadRequested),
switchMap(() => this.api.loadConfig()),
map(config => ConfigActions.loaded({ config })),
take(1),
),
);
// 失敗を握りつぶすつもりで置いた EMPTY
syncSilently$ = createEffect(() =>
this.actions$.pipe(
ofType(SyncActions.requested),
switchMap(() => this.api.sync()),
catchError(() => EMPTY),
),
);take(1) を外側に置くと、1件処理した時点でEffectが complete する。「起動時に1回だけ読み込む」意図であれば期待どおりに見えるが、設定の再読み込みが必要になった時点で動かなくなる。
EMPTY への置き換えはさらに分かりにくい。EMPTY は値を出さずに complete するため、失敗したことを示すActionすら流れない。Effectは静かに終了し、ログにも痕跡が残らない。冒頭に挙げた「エラーログにも何も出ない」不具合の正体がこれである。
@Reviewer: `catchError(() => EMPTY)` で失敗を握りつぶしていますが、これはEffect自体を完了させます。失敗を無視したいだけであれば、内側で捕捉して `EMPTY` を返してください。なお、catchError を置かずに未処理のまま error を投げた場合も、そのEffectは error で終了する。「捕捉していないと落ちる」ことは知られていても、「外側で捕捉しても止まる」ことは見落とされやすい。
rxMethod に現れる同じ構造
SignalStore の rxMethod も、接続した入力を処理し続ける1本のパイプラインである。構造が同じなので、エラーの扱いも同じ結果になる。
withMethods((store, api = inject(UserApi)) => ({
search: rxMethod<string>(
pipe(
debounceTime(300),
switchMap(keyword => api.search(keyword)),
tap(users => patchState(store, { users })),
catchError(() => EMPTY),
),
),
})),一度でも通信が失敗すると、以後は入力を変えても検索が走らなくなる。捕捉は switchMap の内側へ置くか、tapResponse を使う。
Effect と rxMethod は別の仕組みだが、「購読され続ける1本のストリームを、外側の置き換えで終わらせてしまう」という構造は共通している。
retry を付けるときの確認
エラー処理の周辺では retry の扱いも合わせて確認したい。
retry が行うのは、error が起きたときの再購読である。失敗する処理を成功させる仕組みではない。引数なしの retry() は回数の上限がないため、恒久的に失敗する相手に対しては再購読を繰り返し続ける。
書き込み処理に付ける場合はもう一段の確認が要る。1回目のリクエストがサーバーに届いて処理された後にレスポンスだけ失敗した場合、再購読は同じ更新をもう一度実行する。回数、待ち時間、対象とするエラーに加えて、その処理が冪等かどうかを確認する。
@Reviewer: `retry()` に回数の指定がありません。保存処理なので、同じ更新が二重に実行されないことを確認したうえで、回数と対象エラーを絞ってください。レビュー観点チェックリスト
catchErrorがswitchMapなどの内側に置かれているか- 外側の
catchErrorで、EffectやrxMethodを終了させていないか catchError(() => EMPTY)で、失敗の痕跡まで消していないか- 外側の
take(1)が、再実行の必要な処理を1回で止めていないか retryに回数と対象エラーの指定があるかretryを付けた書き込み処理が冪等か
おわりに
catchError があるかどうかだけを見ると、この不具合は見つからない。見るべきなのは、それがどのObservableを置き換えるのかである。
判断の手順は単純にできる。catchError から上流をたどり、actions$ や接続された入力にぶつかるなら、その捕捉はEffect本体を置き換えている。ぶつからずに内部Observableの先頭で止まるなら、正しい位置にある。