非同期処理のエラーがErrorBoundaryで捕捉できなかった話
この記事は「Medley (メドレー) Advent Calendar 2024」の16日目の記事となります。
はじめに
こんにちは、メドレーの村上です。
今回はTanStack Query(旧React Query)を使用した非同期処理のエラーハンドリングで遭遇した問題とその解決方法について共有させて頂きます。
使用しているライブラリとバージョンはこちらです。
{
"react": "^19.0.0",
"@tanstack/react-query": "^5.62.7",
"@sentry/react": "^8.43.0"
}
発生した問題
以下のように、useEffectでmutateAsyncを呼び出しており、mutationFnで例外がスローされてしまいました。
export const App: FC = () => {
const { mutateAsync } = useMutation({
mutationFn: async () => {
// APIリクエストが失敗した場合にエラーをスロー
throw new Error("初期化に失敗しました");
},
});
useEffect(() => {
void mutateAsync();
}, [mutateAsync]);
return <div>サンプルコード</div>;
};
このコンポーネントは以下のように SentryのErrorBoundary で囲まれていましたが、mutateAsync
でスローされたエラーが捕捉されておらず、fallback の UI が表示されませんでした。
こちらが、今回発生した問題になります。
<Sentry.ErrorBoundary
fallback={() => <div>エラーが発生しました</div>}
>
<App/>
</Sentry.ErrorBoundary>
原因調査
エラーが捕捉されない原因を特定するため、以下のステップで調査を進めました。
1. Reactのライフサイクル内でのエラー確認
まず、useEffect内で直接エラーをスローした場合は、ErrorBoundaryで正しく捕捉されることを確認しました。
useEffect(() => {
throw new Error("エラーテスト");
}, []);
このケースではErrorBoundaryが正常に機能し、fallback UIが表示されました。エラーがReactのライフサイクル内で発生する場合は、ErrorBoundaryがエラーを捕捉するようです。
2. mutateとmutateAsyncの違いを確認
TanStack Query の Mutation 実行方法には mutateAsync の他に、mutate があります。
// mutateは void を返す(エラー時はonErrorコールバックで処理)
mutate: (variables: TVariables, { onSuccess, onSettled, onError }) => void
// mutateAsyncは Promise を返す(エラー時は例外をスロー)
mutateAsync: (variables: TVariables, { onSuccess, onSettled, onError }) => Promise<TData>
返り値に違いがあり、mutateAsyncはPromiseを返しており、非同期処理のため、エラー時に例外をスローします。今回の問題では、mutateAsyncを使用していたため、mutationFnでスローされたエラーは非同期処理の中で発生したエラーであることが分かります。
3. ErrorBoundary の仕様確認
ErrorBoundaryの仕様を確認したところ、ErrorBoundaryは配下のコンポーネントツリーでのレンダリング時、ライフサイクルメソッド内、コンストラクタ内で発生したエラーのみを捕捉する仕様でした。
ただし、以下の条件ではエラーは捕捉されないことが書かれていました。
- イベントハンドラ
- 非同期コード(例:setTimeout や requestAnimationFrame のコールバック)
- サーバサイドレンダリング
- ErrorBoundary自身がスローしたエラー
調査結果
これらから、ErrorBoundary はレンダリング中にスローされたエラーを捕捉しますが、mutateAsync
のエラーは、非同期のコードのエラーであるため、ErrorBoundary では捕捉できないことが分かりました。
解決方法
TanStack Query の throwOnError オプション
原因が分かったので、解決方法を調べた所、TanStack Query の mutation オプションには throwOnError
というものがありました。このオプションを true
に設定することで、mutation のエラーを ErrorBoundary で捕捉できるようになりました。
const { mutateAsync } = useMutation({
mutationFn: async () => {
throw new Error("初期化に失敗しました");
},
throwOnError: true // これを追加することでErrorBoundaryでエラーを捕捉できる
});
なぜ解決できたのか
throwOnError: true
を設定すると、TanStack Query は mutation のエラーを内部で捕捉し、それをReactのレンダリング中にスローし直します。実際のソースコードはこちらです。
// TanStack/query/packages/react-query/src/useMutation.ts
export function useMutation<TData, TError, TVariables, TContext>(
options: UseMutationOptions<TData, TError, TVariables, TContext>,
queryClient?: QueryClient,
) {
// ...
if (
result.error &&
shouldThrowError(observer.options.throwOnError, [result.error])
) {
throw result.error // Reactのレンダリング中にエラーをスロー
}
return { ...result, mutate, mutateAsync: result.mutate }
}
このコードから分かるように、Reactのレンダリング中にエラーをスローすることで、ErrorBoundaryでの捕捉が可能になっています。これは非同期処理で発生したエラーをReactのレンダリング中にスローし直すことで実現されていました。
throwOnError を使わないで解決する方法
上記のthrowOnError
の実装から分かるように、throwOnErrorを使用しなくても、エラーをReactのレンダリング中にスローし直すことで、解決することもできました。
export const App: FC = () => {
const { mutateAsync, error } = useMutation({
mutationFn: async () => {
throw new Error("初期化に失敗しました");
},
});
useEffect(() => {
void mutateAsync();
}, [mutateAsync]);
if (error) {
throw error;
}
return <div>サンプルコード</div>;
};
このように、mutationのエラー状態をisError
を用いて監視し、エラーが発生した場合にレンダリング中にエラーをスローすることで、ErrorBoundaryでエラーを捕捉することができました。
まとめ
今回は、TanStack QueryのmutateAsync
を使用した際に、ErrorBoundaryでエラーが捕捉されないという問題に対して、TanStack QueryのthrowOnError
オプションを使用することで解決できました。このオプションにより、非同期処理で発生したエラーをReactのレンダリング中にスローし直すことができ、結果としてErrorBoundaryでのエラー捕捉が可能になりました。
実装する際は、使用しているライブラリの仕様や提供されているオプションをよく確認していきたいと思いました!
終わりに
メドレーではエンジニアを募集しているので、興味がある方はぜひお声かけください。
Medley Advent Calendar 2024、明日は @tamutamuQA さんです!
参考文献
Sentry Documentation - React Error Boundary
旧 React 公式ドキュメント - ErrorBoundary
Mastering Mutations in React Query
Migrating to TanStack Query v5 - The useErrorBoundary option has been renamed to throwOnError
TanStack Query - react-query/src/useMutation.ts
Discussion