Cleaning up API error handling in a React project with ApiErrorBoundary
I pulled the react-query error handling I'd been copying onto every page into one ApiErrorBoundary. Why useErrorBoundary is needed and how network errors go to GlobalErrorBoundary.
#react-query #ErrorBoundary #zustand
I use react-query and zustand together in my React projects. zustand holds global state like login info, and react-query fetches data from the server. The combination itself was never a problem, but at some point I started noticing that the code that handles a failed API call was repeated far too much . I kept copying and pasting the "Failed to load data" and "Try again" screens into every page, so whenever something needed fixing I had to hunt it down all over the place, and the error screens looked slightly different from page to page, which felt messy for the people using it too. So I built a component called ApiErrorBoundary on top of react-error-boundary, and I've been pretty happy with it since. In this post I'll write down how the code changed before and after adding it, and how I laid out the structure. I was handling errors on every page At first I used the isError, error and refetch values that react-query gives you as they were, and handled errors inside the page. The code back then looked roughly like this. const { data, isLoading, isError, error } = useQuery(['getCode'], getCode); useEffect(() = { if (data) { setCodes(data); } return () = { resetCodes(); }; }, [data]); if (isLoading) return Loading / ; if (isError) { return ( div p An error occurred /p button onClick={() = refetch()} Try again /button /div ); } It was fine when there were only a few pages. But as the pages grew, that same if (isError) block kept showing up everywhere. On top of that, printing error.message straight to the screen isn't very friendly to show a user, and it was hard to keep the error screen looking the same on every page. Components that only needed to handle loading were carrying error handling as well, so the code got longer too. I built ApiErrorBoundary So I used the react-error-boundary library to build a component called ApiErrorBoundary. The idea is simple. Instead of handling errors inside the component that uses the query, catch them all in one place, outside, in whatever wraps that component. There's one thing to know here. react-query doesn't throw errors by default. When a request fails it only flips isError to true and puts the error in error, so the component has to look at those values and handle it itself. But an ErrorBoundary only catches errors thrown during rendering. So to connect the two, you have to tell react-query "just throw when it fails", and the option for that is useErrorBoundary: true . If you give useQuery useErrorBoundary: true, react-query throws the error during rendering when the query fails The nearest ErrorBoundary, meaning ApiErrorBoundary, catches that error and renders the fallback screen instead ApiErrorBoundary itself is little more than a wrapper around the ErrorBoundary from react-error-boundary. The component to show when an error happens is passed in as FallbackComponent. // ApiErrorBoundary.tsx const ApiErrorBoundary: React.FC { children: React.ReactNode } = ({ children }) = { return ( ErrorBoundary FallbackComponent={ApiErrorFallback} onReset={() = { // if anything needs resetting on retry, do it here }} {children} /ErrorBoundary ); }; export default ApiErrorBoundary; onReset is what gets called when retry is pressed in the fallback. It's called right before the boundary clears its error state and renders the children again, so you can roll back store values or clear a cache here. In my case I left it empty. When the children mount again, useQuery runs again and requests the failed query one more time on its own, so there was no need to call refetch separately. The fallback screen looks like this. react-error-boundary passes in two props, error and resetErrorBoundary. // ApiErrorFallback.tsx export const ApiErrorFallback = ({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () = void; }) = { const statusCode = (error as any)?.response?.status || null; console.log(error.message); // a dropped network isn't something to handle here, so pass it outward if (error.message === 'Network Error') { throw error; } return ( div role="alert" className="flex flex-col items-center justify-center w-[100%] h-[100%]" span {error.message} /span span {statusCode} /span span className="text-[24px]" An error occurred while loading the data. /span IoIosRefresh className="text-[40px] text-gray-500 cursor-pointer hover:text-black" onClick={resetErrorBoundary} / /div ); }; Calling resetErrorBoundary makes the boundary clear its error state and render the original children again. So a single refresh icon takes over the whole job of the old "Try again" button. The status code comes from response.status on the axios error, and I print it alongside so that later I can handle errors that mean different things, like 401 or 404, separately. Once this is in place, all the error handling on the component side happens in this one fallback. A component that uses a query only has to be wrapped in ApiErrorBoundary. ApiErrorBoundary CommentFetcher CommentManage / /CommentFetcher /ApiErrorBoundary And the query inside gets just one extra line, useErrorBoundary: true. const { data, isLoading, isError, error } = useQuery('getComments', getComments, { useErrorBoundary: true, }); Now there's nothing to do with isError or refetch inside the query. All that's left is deciding what to show while loading. Compared with the old code above, the error-related lines are gone entirely. A dropped network is caught separately ApiErrorBoundary only takes care of API errors at the component level. But it's a different story when the internet is down completely or the server has gone down. That isn't one comment failing to load, it's the whole app not working, so showing "Try again" on part of the screen is pointless. So I had GlobalErrorBoundary wrap the entire app and catch those. This is how I laid it out. GlobalErrorBoundary sits outside the router, at the very outermost level. GlobalErrorBoundary BrowserRouter QueryClientProvider client={queryClient} App / /QueryClientProvider /BrowserRouter /GlobalErrorBoundary The component itself is almost the same as ApiErrorBoundary, and only the fallback is different. // GlobalErrorBoundary.tsx const GlobalErrorBoundary: React.FC { children: React.ReactNode } = ({ children }) = { return ( ErrorBoundary FallbackComponent={GlobalErrorFallback} onReset={() = {}} {children} /ErrorBoundary ); }; export default GlobalErrorBoundary; The fallback covers the whole screen. For a network error it shows a notice that "the server is down", and for anything else it shows a button that goes back to home. // GlobalErrorFallback.tsx export const GlobalErrorFallback = ({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () = void; }) = { const statusCode = (error as any)?.response?.status || null; console.log(error.message); return ( div role="alert" className="flex flex-col items-center justify-center w-[100vw] h-[100vh] p-2" Lottie options={defaultOptions} width={'100%'} height={'auto'} / {error.message != 'Network Error' ? ( span className="text-white text-center" An error occurred while loading the data. /span button className="px-4 py-2 bg-blue-500 text-white rounded" onClick={resetErrorBoundary} Back to home /button / ) : ( span className="text-white text-center" The server is currently down. /span span className="text-white text-center mb-[10px]" Please try again in a moment. /span / )} /div ); }; This is why there was a throw error inside ApiErrorFallback earlier. A fallback is a component too, so if it throws during rendering, the boundary outside it catches the error. So a Network Error gets caught by the inner boundary and thrown again, and GlobalErrorBoundary catches that and switches to the full screen. The boundaries are two layers deep, yet which error gets handled where comes down to that one throw line. What changed after the switch After moving to this structure, the components that use queries were left with almost nothing but loading handling. The error screens are managed through two fallbacks, so they look consistent, and when something needs fixing I only have to look in one place. For users too, whichever page the error happens on, it's the same screen with the same refresh button, so it's less confusing. To sum up: ApiErrorBoundary catches failed API requests GlobalErrorBoundary catches a dropped network or app-wide errors react-query needs useErrorBoundary: true for errors to reach the boundary When auth expires and a 401 comes back, I clear the user state in zustand to log the user out If your error handling feels repetitive, I'd recommend trying to lift it up into a boundary. It only took one library and two components. And a year later this option was gone I'd set up this structure and was using it happily, but about a year later I opened the docs thinking about upgrading react-query to TanStack Query v5, and the useErrorBoundary option had disappeared. It had been renamed to throwOnError. I haven't upgraded yet, and all my projects including this blog are still on v3, but it means that the day I do, the code in this post won't run as it is. // what I wrote on v3 useQuery(key, fn, { useErrorBoundary: true }) // v5 useQuery({ queryKey, queryFn, throwOnError: true }) Only the option name changed, so the fix itself should be quick. But since I'd spread ApiErrorBoundary across many pages, I'll have to track down the query options one by one. I thought that once you build an abstraction, changes happen in one go too, but even with the boundary in one place, the option that turns it on is scattered across every query, so that's not really the case. I wrote up what else changed in v5 separately.