I went to upgrade react-query v3 to TanStack Query v5 and useErrorBoundary was gone
From react-query v3 to TanStack Query v5, useErrorBoundary became throwOnError. Covers the single object argument, isPending and the removal of onSuccess, and why each changed.
#TanStack Query #react-query #Frontend #Refactoring #React
A while back I wrote a post about handling API errors in one place with react-query (v3) and useErrorBoundary. I'd been happily using that setup, and thinking I'd have to upgrade someday, I opened the TanStack Query v5 migration docs and found that useErrorBoundary had been renamed to throwOnError . My old code wouldn't run if I upgraded it as is. In this post I go over what changed from v3 to v5, and why it was changed that way . Looking at the reasons shows what this library came to regret. The name changed first From v4, react-query was renamed to @tanstack/react-query . As the same core came to be used in Vue and Svelte as well as React, it dropped the name of one specific framework and moved under the TanStack Query umbrella. The import path is the first thing to change. // v3 import { useQuery, QueryClient } from 'react-query'; // From v4 import { useQuery, QueryClient } from '@tanstack/react-query'; useErrorBoundary became throwOnError This is the option my post #49 was built around. It's the one that throws the error to an ErrorBoundary when a query fails, and it was renamed to throwOnError . The reason is simple. The name describes what it does more accurately . The actual behavior isn't "use an error boundary", it's "throw the error". Who catches the thrown error is none of this option's business. // v3 useQuery('posts', fetchPosts, { useErrorBoundary: true }); // v5: the name changes, and so does the argument structure useQuery({ queryKey: ['posts'], queryFn: fetchPosts, throwOnError: true }); // In v5 it can also be a function. For example, throw only server errors to the boundary and handle 404 on the screen useQuery({ queryKey: ['posts'], queryFn: fetchPosts, throwOnError: (error) = error.response?.status = 500, }); The last example is something v3 didn't have. My ApiErrorBoundary passes every failure to the boundary and branches on statusCode inside the fallback, but in v5 the query side can decide first whether to throw or not. Arguments were unified into a single object As the code above shows, the way you call it changed too. In v3 there were several ways to pass arguments , like useQuery(key, function, options). You could pass only the key, or the function as well, or a single object. There were too many of these overloads. v5 got rid of all of them and settled on a single object . You put queryKey and queryFn in an object and pass that. The reason for the change is that the more overloads there were, the more the library had to check at runtime, every time, "what shape are the arguments that just came in", and the type definitions got that much more complicated. Cutting the entry points down to one made maintenance and the types simpler. On the calling side, there's also no more confusion over whether the key is a string or an array. The status names changed too, loading became pending This is another change that straightened out something confusing. The old status 'loading' became 'pending' , and isLoading became isPending. In its place there's a new isLoading, defined as isPending && isFetching . In other words, it's true only when "there's no data yet (first load) + it's being fetched right now". The names were cleaned up to clearly separate "loading for the first time" from "refreshing in the background". v3 separated these two with isLoading and isFetching, but it was hard to tell which was which from the names alone. const { data, isPending, isFetching, isLoading } = useQuery({ queryKey, queryFn }); // isPending No data yet (not in the cache either) // isFetching A request is out right now (first request or background refresh) // isLoading When both are true. That is, only while loading for the first time The onSuccess and onError callbacks were dropped from useQuery This one threw me the most at first. The onSuccess and onError callbacks that were called when a query succeeded or failed were removed entirely from useQuery. The reason is convincing. These callbacks weren't called consistently on every re-render , so people kept getting confused and writing bugs. Sometimes they weren't called at all when cached data was used, so "why isn't it being called?" kept coming up. There was also the problem that using the same query in two components made the callback run twice. So side effects are now meant to be handled outside useQuery. If something has to happen when data arrives, move it to useEffect, and if it's success handling for a single request, move it to the mutation side. Rather than shoring up a confusing feature, the decision was to take it out altogether . Other things that changed The keepPreviousData option was absorbed into placeholderData , and if you want Suspense you now use a separate dedicated hook, useSuspenseQuery . Also, v5 uses useSyncExternalStore internally, so React 18 or later is required . My projects are all on 18, so that doesn't block me. Fortunately there's an official codemod, and I'm told it swaps out the renamed things automatically to some extent. It isn't perfect, but it looks like it would save a lot of manual work. # v4 - v5 codemod. Converts calls to the single-object form (path is from the official docs, may differ by version) npx jscodeshift ./src \ --extensions=ts,tsx \ --parser=tsx \ --transform=./node_modules/@tanstack/react-query/build/codemods/src/v5/remove-overloads/remove-overloads.cjs But I haven't actually upgraded To be honest about it, as of September 2026, when I'm revising this post, not one of my projects uses v5. I opened every project that uses react-query and they all look like this. blog-server/blog "react-query": "^3.39.3" couple-server/couple-restaurant-share "react-query": "^3.39.3" game-server/jaeyonging-game "react-query": "^3.39.3" taro-server/taroSite "react-query": "^3.39.3" So the ApiErrorBoundary I built in post #49 still uses the old option name too. The changes listed above are what I got from following the migration docs and applying them to my code on paper, not something I went through in a real upgrade. The reason I don't upgrade when I could is simple. The blog runs fine, and I only touch it when it doesn't . Now that I keep several personal projects running, "the thing that runs fine" is turning into "the most outdated thing". Still, knowing that the option name changed should mean less fumbling when I do upgrade later. If you're on v3 or v4 right now, I'd recommend reading at least the throwOnError and onSuccess sections of the migration docs ahead of time.