Skip to content
TkDodo's blog
TwitterGithub

React Query Error Handling

ReactJs, React Query, JavaScript, TypeScript4 min read

error handling
Photo by Sigmund

Last Update: 21.10.2023

  • 한국어
  • Add translation

Handling errors is an integral part of working with asynchronous data, especially data fetching. We have to face it: Not all requests will be successful, and not all Promises will be fulfilled.

Oftentimes, it is something that we don't focus on right from the beginning though. We like to handle "sunshine cases" first where error handling becomes an afterthought.

However, not thinking about how we are going to handle our errors might negatively affect user experience. To avoid that, let's dive into what options React Query offers us when it comes to error handling.

Prerequisites

React Query needs a rejected Promise in order to handle errors correctly. Luckily, this is exactly what you'll get when you work with libraries like axios.

If you are working with the fetch API or other libraries that do not give you a rejected Promise on erroneous status codes like 4xx or 5xx, you'll have to do the transformation yourself in the queryFn. This is covered in the official docs.

The standard example

Let's see how most examples around displaying errors look like:

the-standard-example
1function TodoList() {
2 const todos = useQuery({
3 queryKey: ['todos'],
4 queryFn: fetchTodos
5 })
6
7 if (todos.isPending) {
8 return 'Loading...'
9 }
10
11 // ✅ standard error handling
12 // could also check for: todos.status === 'error'
13 if (todos.isError) {
14 return 'An error occurred'
15 }
16
17 return (
18 <div>
19 {todos.data.map((todo) => (
20 <Todo key={todo.id} {...todo} />
21 ))}
22 </div>
23 )
24}

Here, we're handling error situations by checking for the isError boolean flag (which is derived from the status enum) given to us by React Query.

This is certainly okay for some scenarios, but has a couple of drawbacks, too:

  1. It doesn't handle background errors very well: Would we really want to unmount our complete Todo List just because a background refetch failed? Maybe the api is temporarily down, or we reached a rate limit, in which case it might work again in a few minutes. You can have a look at #4: Status Checks in React Query to find out how to improve that situation.

  2. It can become quite boilerplate-y if you have to do this in every component that wants to use a query.

To solve the second issue, we can use a great feature provided directly by React itself:

Error Boundaries

Error Boundaries are a general concept in React to catch runtime errors that happen during rendering, which allows us to react (pun intended) properly to them and display a fallback UI instead.

This is nice because we can wrap our components in Error Boundaries at any granularity we want, so that the rest of the UI will be unaffected by that error.

One thing that Error Boundaries cannot do is catch asynchronous errors, because those do not occur during rendering. So to make Error Boundaries work in React Query, the library internally catches the error for you and re-throws it in the next render cycle so that the Error Boundary can pick it up.

I think this is a pretty genius yet simple approach to error handling, and all you need to do to make that work is pass the throwOnError flag to your query (or provide it via a default config):

throwOnError
1function TodoList() {
2 // ✅ will propagate all fetching errors
3 // to the nearest Error Boundary
4 const todos = useQuery({
5 queryKey: ['todos'],
6 queryFn: fetchTodos,
7 throwOnError: true,
8 })
9
10 if (todos.data) {
11 return (
12 <div>
13 {todos.data.map((todo) => (
14 <Todo key={todo.id} {...todo} />
15 ))}
16 </div>
17 )
18 }
19
20 return 'Loading...'
21}

Starting with v3.23.0, you can even customize which errors should go towards an Error Boundary, and which ones you'd rather handle locally by providing a function to throwOnError:

granular-error-boundaries
1useQuery({
2 queryKey: ['todos'],
3 queryFn: fetchTodos,
4 // 🚀 only server errors will go to the Error Boundary
5 throwOnError: (error) => error.response?.status >= 500,
6})

This also works for mutations, and is quite helpful for when you're doing form submissions. Errors in the 4xx range can be handled locally (e.g. if some backend validation failed), while all 5xx server errors can be propagated to the Error Boundary.

Showing error notifications

For some use-cases, it might be better to show error toast notifications that pop up somewhere (and disappear automatically) instead of rendering Alert banners on the screen. These are usually opened with an imperative api, like the one offered by react-hot-toast:

react-hot-toast
1import toast from 'react-hot-toast'
2
3toast.error('Something went wrong')

So how can we do this when getting an error from React Query?

The onError callback

the-onError-callback
1const useTodos = () =>
2 useQuery({
3 queryKey: ['todos'],
4 queryFn: fetchTodos,
5 // ⚠️ looks good, but is maybe _not_ what you want
6 onError: (error) =>
7 toast.error(`Something went wrong: ${error.message}`),
8 })

At first glance, it looks like the onError callback is exactly what we need to perform a side effect if a fetch fails, and it will also work - for as long as we only use the custom hook once!

You see, the onError callback on useQuery is called for every Observer, which means if you call useTodos twice in your application, you will get two error toasts, even though only one network request fails.

Conceptually, you can imagine that the onError callback functions similar to a useEffect. So if we expand the above example to that syntax, it will become more apparent that this will run for every consumer:

useEffect-error-toast
1const useTodos = () => {
2 const todos = useQuery({
3 queryKey: ['todos'],
4 queryFn: fetchTodos
5 })
6
7 // 🚨 effects are executed for every component
8 // that uses this custom hook individually
9 React.useEffect(() => {
10 if (todos.error) {
11 toast.error(`Something went wrong: ${todos.error.message}`)
12 }
13 }, [todos.error])
14
15 return todos
16}

Of course, if you don't add the callback to your custom hook, but to the invocation of the hook, this is totally fine. But what if we don't really want to notify all Observers that our fetch failed, but just notify the user once that the underlying fetch failed? For that, React Query has callbacks on a different level:

The global callbacks

The global callbacks need to be provided when you create the QueryCache, which happens implicitly when you create a new QueryClient, but you can also customize that:

query-cache-callbacks
1const queryClient = new QueryClient({
2 queryCache: new QueryCache({
3 onError: (error) =>
4 toast.error(`Something went wrong: ${error.message}`),
5 }),
6})

This will now only show an error toast once for each query, which exactly what we want.🥳 It is also likely the best place to put any sort of error tracking or monitoring that you want to perform, because it's guaranteed to run only once per request and cannot be overwritten like e.g. the defaultOptions.

Putting it all together

The three main ways to handle errors in React Query are:

  • the error property returned from useQuery
  • the onError callback (on the query itself or the global QueryCache / MutationCache)
  • using Error Boundaries

You can mix and match them however you want, and what I personally like to do is show error toasts for background refetches (to keep the stale UI intact) and handle everything else locally or with Error Boundaries:

background-error-toasts
1const queryClient = new QueryClient({
2 queryCache: new QueryCache({
3 onError: (error, query) => {
4 // 🎉 only show error toasts if we already have data in the cache
5 // which indicates a failed background update
6 if (query.state.data !== undefined) {
7 toast.error(`Something went wrong: ${error.message}`)
8 }
9 },
10 }),
11})

That's it for today. Feel free to reach out to me on twitter if you have any questions, or just leave a comment below. ⬇️