Migration guide
Upgrade react-query to v5
Upgrading react-query a major version, with the traps mapped out first.
This is a pull request I opened at InterNations. It is laid out below the way my colleagues would have come across it: as a review page, at the point where they had to decide whether to take the change on.
The description is the part on display here. The diff that went with it is private, and links to the repository and to our internal tools are flattened to plain text, so nothing below will take you somewhere it shouldn’t.
Upgrade react-query to v5#6663
React Query v5 Upgrade
Video walkthrough of these notes: (internal recording, not included here)
- Migration docs: https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5
- Rationale behind the breaking changes: https://github.com/TanStack/query/discussions/4252
- Explanation of the
onSuccess/onErrordeprecation: https://tkdodo.eu/blog/breaking-react-querys-api-on-purpose
How we use query hooks
react-query provides hooks like useQuery, useInfiniteQuery, and useMutation
We create our own “data hooks” that wrap around these library hooks. We normally don’t call the library hooks directly.
Our goals:
- One data hook per API endpoint
- Ensure consistency around the query function and query key
- re: query function—ensure that we use the correct endpoint URL and pass the right parameters
- re: query key—ensure that the library can cache our data in an efficient way, and so that we know where to find the cached data if we need to retrieve it or mutate it
For example, we have a bunch of hooks that store member data. Imagine if they had query keys named “member”, “members”, “users”, etc, instead of all rallying around a single, consistent key.
Function signature
useQuery now only supports one function signature. No more overloads.
Why?
The
useQuery.tsfile has 140 lines of code - only 3 of which are actual JavaScript. https://github.com/TanStack/query/discussions/4252
useQuery arguments are now just one object
// BeforeuseQuery(queryKey, queryFn, options)
// AfteruseQuery({ queryKey, queryFn, ...options,})Same with mutations
// BeforeuseMutation(mutationFn, options)
// AfteruseQuery({ mutationFn, ...options,})Beware spreading objects
What’s the difference between these two calls?
useQuery({ queryKey, queryFn, ...options,})
useQuery({ ...options, queryKey, queryFn,})Keep in mind that options now includes the query key and query function!
We’ve been using options as an object that contains all the other stuff, besides the query key & query function. But the whole point of our query hooks is to make sure the key and function are consistent. Therefore, let’s continue using options only for the other stuff.
This means the type usually needs to be changed:
// Beforefunction useMyHook(requestArgs: RequestArgs, options: UseQueryOptions) { return useQuery(/* ... */)}
// Afterfunction useMyHook(requestArgs: RequestArgs, options: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>) { return useQuery(/* ... */)}Be mindful of options we do want to override
Those should still go above ...options:
useQuery({ staleTime: 5 * 60 * 1000, ...options, queryKey, queryFn,})
useMutation({ onMutate: () => { /* ... */ }, ...options, mutationFn,})BugWatch!
Moving ...options to the top could uncover some subtle bugs that we’ve had all along where someone passed in a custom queryKey or queryFn.
If problems occur, try debugging by moving ...options to the bottom of the list and see if it “fixes” the issue.
useQuery({ ...options, queryKey, queryFn,})
useMutation({ ...options, mutationFn,})useInfinityQuery pageParam
Recall that we have our own useInfiniteQuery that wraps around the third-party library’s hook.
You’ve probably never thought about pageParam 😅 (I sure hadn’t). It’s what useInfiniteQuery uses to fetch the next page of data.
Recap
For offset-paginated data, pageParam is a number—the index of the first piece of data.
Assuming you’re fetching 10 things at a time:
- First page:
pageParam = 0 - Second page:
pageParam = 10 - Third page:
pageParam = 20
For cursor-paginated data, it’s a number of string that the backend gives us.
But all of this is handled for you automatically by our custom useInfiniteQuery, so you don’t need to pass it.
Why you might care about the pageParam
For TypeScript reasons.
The library now types the pageParam as unknown, which means we need to explicitly set the type when using generics in some cases.

Hovering over QueryFunctionContext, we see it takes another generic, TPageParam:

The solution is to assign number to that param:

Disabling queries until some param is defined
We have a common pattern in our code where we need some ID before we can fetch data, but the ID might be temporarily undefined. Of course, we don’t want the query hook to actually fetch anything until the ID has been resolved.

Our current solution:
- Allow the request creator to receive
undefined - Use the
enabledflag to prevent the request from actually firing - Cross our fingers and hope that these two pieces of code remain in harmony forever and ever

Even worse, what if our hook also accepts options? We need to make sure that our enabled doesn’t clobber the enabled value that someone might pass in:

This works, but it could be better.
- Nearly every developer has had trouble when they first encountered the expression
options?.enabled !== false - We mention
optionstwice - Our request creator should not accept
undefined
Solution: use RQ’s skipToken, which tells RQ to disable the queryFn in that case:

Keep in mind that skipToken does not work when we use enabled: false and manually fetch queries:
const { refetch } = useQuery({ queryKey, // Throws a runtime error! queryFn: id ? () => requester(doStuff(id)) : skipToken, enabled: false,})‘Loading’ is now ‘Pending’. Or is it?
useQuery & useInfiniteQuery
The variable isLoading has changed its name:
| Version 4 | Version 5 | What it means |
|---|---|---|
isLoading | isPending | “there’s no cached data and no query attempt was finished yet” |
isInitialLoading | isLoading | “is true whenever the first fetch for a query is in-flight” |
Confusingly, there is still an isLoading, but its meaning has changed 😅
Timeline

isPendingis true.isLoadingis false. Nothing is happening on the network.- Network request begins.
isPendingremains true.isLoadingnow becomes true. - Network request finishes.
isPendingbecomes false.isLoadingbecomes false.
What to change?
I think we should switch from isLoading to isPending.
Reasons:
- Our components are already organized around showing a skeleton until data is ready. We can use
isPending === trueto determine this. We cannot continue usingisLoadingin this way because it will befalseat first. - Type safety.
databecomes defined whenisPendingis no longer true. This means that when we doif (isPending) { return }, TypeScript will know that after the returndatais defined so we don’t need to do aBoolean(data)check before accessing it.
/** * Does not work*/const { data, isLoading } = useQuery<string>(...)
if (isLoading) { return}
data.toLowercase()// Error: data might be undefined
/** * Works*/const { data, isPending } = useQuery<string>(...)
if (isPending) { return}
data.toLowercase()// No error, data is guaranteed to be definedSince I think we may discuss the topic of isLoading/isPending a bit, these changes will go into a separate PR.
useMutation
The change is more straightforward:
| Version 4 | Version 5 | What it means |
|---|---|---|
| isLoading | isPending | We’re waiting for a POST/PUT/PATCH/DELETE request to finish |
This change is required, so I’m updating it in this PR.
onSuccess & onError are deprecated
Only deprecated for useQuery & useInfiniteQuery. Still available for useMutation.
Why? I need those!
We don’t really need them, and they can cause/encourage bugs: https://tkdodo.eu/blog/breaking-react-querys-api-on-purpose
Error handling
Status quo:
- Create a default error handler that’s defined when we setup the query provider
- Override this on a per-hook basis by defining an
onErrorcallback- Use cases:
- Silencing the default error snackbar
- Ignoring some types of errors (e.g. sometimes it’s okay to receive a 404)
- Use cases:
New option: meta.silenceErrors
// Simple use case: always silence the default snackbaruseQuery({ meta: { silenceErrors: true, },})
// Nuanced logicuseQuery({ meta: { silenceErrors(error) { if (error.status === 404) { // Silence it return true }
// Don't silence it return false // or undefined }, },})Note: This applies only to useQuery & useInfiniteQuery. It does not work for useMutation. Mutations should use the onError handler.
Custom error message text
You can define custom text to appear in the default error snackbar:
useQuery({ meta: { errorMessage: 'My custom text', },})Again, does not work for useMutation.
BugWatch!
We now log all errors, whereas before we sometimes completely ignored them. We might find some issues in our logs that were happening before, but were always silenced.
Other changes
cacheTimehas been renamed togcTime(“garbage collection”). No change in functionality.- A bunch of
queryClient.xxxxmethods now require the object syntax - Added an ESLint plugin
@tanstack/eslint-plugin-query - No more custom logger
- We were using this in a couple tests to silence the errors—we can now do that by mocking
logErrororconsole.errordirectly
- We were using this in a couple tests to silence the errors—we can now do that by mocking
New features
https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#new-features-
None of these have been implemented yet
- Simplified optimistic updates
maxPagesfor infinite queries- and more
