TanStack Query is a powerful data-fetching and state management library. Since the release of TanStack Query v5, many developers upgrading to the new version have faced challenges in migrating their existing functionality. While the official documentation covers all the details, it can be overwhelming, making it easy to miss important updates.
In this blog, we’ll explain the main updates in TanStack Query v5 and show how to make the switch smoothly.
For a complete list of changes, check out the TanStack Query v5 Migration Guide.
In previous versions of React Query, functions like useQuery
and useMutation
had multiple type overloads. This not only made type maintenance more
complicated but also led to the need for runtime checks to validate the types of
parameters.
To streamline the API, TanStack Query v5 introduces a simplified approach: a single parameter as an object containing the main parameters for each function.
Below are some examples of how commonly used hooks and queryClient
methods
have been restructured.
// before (Multiple overloads)
useQuery(key, fn, options);
useInfiniteQuery(key, fn, options);
useMutation(fn, options);
useIsFetching(key, filters);
useIsMutating(key, filters);
// after (Single object parameter)
useQuery({ queryKey, queryFn, ...options });
useInfiniteQuery({ queryKey, queryFn, ...options });
useMutation({ mutationFn, ...options });
useIsFetching({ queryKey, ...filters });
useIsMutating({ mutationKey, ...filters });
queryClient
Methods:// before (Multiple overloads)
queryClient.isFetching(key, filters);
queryClient.getQueriesData(key, filters);
queryClient.setQueriesData(key, updater, filters, options);
queryClient.removeQueries(key, filters);
queryClient.cancelQueries(key, filters, options);
queryClient.invalidateQueries(key, filters, options);
// after (Single object parameter)
queryClient.isFetching({ queryKey, ...filters });
queryClient.getQueriesData({ queryKey, ...filters });
queryClient.setQueriesData({ queryKey, ...filters }, updater, options);
queryClient.removeQueries({ queryKey, ...filters });
queryClient.cancelQueries({ queryKey, ...filters }, options);
queryClient.invalidateQueries({ queryKey, ...filters }, options);
This approach ensures developers can manage and pass parameters more cleanly, while maintaining a more manageable codebase with fewer type issues.
A significant change in TanStack Query v5 is the removal of callbacks such as
onError
, onSuccess
, and onSettled
from useQuery
and QueryObserver
.
This change was made to avoid potential misconceptions about their behavior and
to ensure more predictable and consistent side effects.
Previously, we could define onError
directly within the useQuery
hook to
handle side effects, such as showing error messages. This eliminated the need
for a separate useEffect
.
const useUsers = () => {
return useQuery({
queryKey: ["users", "list"],
queryFn: fetchUsers,
onError: error => {
toast.error(error.message);
},
});
};
With the removal of the onError
callback, we now need to handle side effects
using React’s useEffect
.
const useUsers = () => {
const query = useQuery({
queryKey: ["users", "list"],
queryFn: fetchUsers,
});
React.useEffect(() => {
if (query.error) {
toast.error(query.error.message);
}
}, [query.error]);
return query;
};
By using useEffect
, the issue with this approach becomes much more apparent.
For instance, if useUsers()
is called twice within the application, it will
trigger two separate error notifications. This is clear when inspecting the
useEffect
implementation, as each component calling the custom hook registers
an independent effect. In contrast, with the onError
callback, the behavior
may not be as clear. We might expect errors to be combined, but they are not.
For these types of scenarios, we can use the global callbacks on the
queryCache
. These global callbacks will run only once for each query and
cannot be overwritten, making them exactly what we need for more predictable
side effect handling.
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: error => toast.error(`Something went wrong: ${error.message}`),
}),
});
Another common use case for callbacks was updating local state based on query data. While using callbacks for state updates can be straightforward, it may lead to unnecessary re-renders and intermediate render cycles with incorrect values.
For example, consider the scenario where a query fetches a list of 3 users and updates the local state with the fetched data.
export const useUsers = () => {
const [usersCount, setUsersCount] = React.useState(0);
const { data } = useQuery({
queryKey: ["users", "list"],
queryFn: fetchUsers,
onSuccess: data => {
setUsersCount(data.length);
},
});
return { data, usersCount };
};
This example involves three render cycles:
data
is undefined and usersCount
is 0 while the query
is fetching, which is the correct initial state.onSuccess
runs, data
will be an array of 3 users. However, since setUsersCount
is asynchronous,
usersCount
will remain 0 until the state update completes. This is wrong
because values are not in-sync.usersCount
is updated to
reflect the number of users (3), triggering a re-render. At this point, both
data
and usersCount
are in sync and display the correct values.The refetchInterval
callback now only receives the query
object as its
argument, instead of both data
and query
as it did before. This change
simplifies how callbacks are invoked and it resolves some typing issues that
arose when callbacks were receiving data transformed by the select
option.
To access the data within the query object, we can now use query.state.data
.
However, keep in mind that this will not include any transformations applied by
the select option. If we need to access the transformed data, we'll need to
manually reapply the transformation.
For example, consider the following code snippet:
const useUsers = () => {
return useQuery({
queryKey: ["users", "list"],
queryFn: fetchUsers,
select: data => data.users,
refetchInterval: (data, query) => {
if (data?.length > 0) {
return 1000 * 60; // Refetch every minute if there is data
}
return false; // Don't refetch if there is no data
},
});
};
This can now be refactored as follows:
const useUsers = () => {
return useQuery({
queryKey: ["users", "list"],
queryFn: fetchUsers,
select: data => data.users,
refetchInterval: query => {
if (query.state.data?.users?.length > 0) {
return 1000 * 60; // Refetch every minute if there is data
}
return false; // Don't refetch if there is no data
},
});
};
Similarly, the refetchOnWindowFocus
, refetchOnMount
, and
refetchOnReconnect
callbacks now only receive the query
as an argument.
Below are the changes to the type signature for the refetchInterval
callback
function:
// before
refetchInterval: number | false | ((data: TData | undefined, query: Query)
=> number | false | undefined)
// after
refetchInterval: number | false | ((query: Query) => number | false | undefined)
The term cacheTime
is often misunderstood as the duration for which data is
cached. However, it actually defines how long data remains in the cache after a
query becomes unused. During this period, the data remains active and
accessible. Once the query is no longer in use and the specified cacheTime
elapses, the data is considered for "garbage collection" to prevent the cache
from growing excessively. Therefore, the term gcTime
more accurately describes
this behavior.
const MINUTE = 1000 * 60;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
- // cacheTime: 10 * MINUTE, // before
+ gcTime: 10 * MINUTE, // after
},
},
})
The keepPreviousData
option and the isPreviousData
flag have been removed in
TanStack Query v5, as their functionality was largely redundant with the
placeholderData
and isPlaceholderData
options.
To replicate the behavior of keepPreviousData
, the previous query data is now
passed as a parameter to the placeholderData
option. This option can accept an
identity function to return the previous data, effectively mimicking the same
behavior. Additionally, TanStack Query provides a built-in utility function,
keepPreviousData
, which can be used directly with placeholderData
to achieve
the same effect as in previous versions.
Here’s how we can use placeholderData
to replicate the functionality of
keepPreviousData
:
import {
useQuery,
+ keepPreviousData // Built-in utility function
} from "@tanstack/react-query";
const {
data,
- // isPreviousData,
+ isPlaceholderData, // New
} = useQuery({
queryKey,
queryFn,
- // keepPreviousData: true,
+ placeholderData: keepPreviousData // New
});
In previous versions of TanStack Query, undefined
was passed as the
default page parameter to the query function in infinite queries. This led to
potential issues with non-serializable undefined
data being stored in the
query cache.
To resolve this, TanStack Query v5 introduces an explicit initialPageParam
parameter in the infinite query options. This ensures that the page parameter is
always defined, preventing caching issues and making the query state more
predictable.
useInfiniteQuery({
queryKey,
- // queryFn: ({ pageParam = 0 }) => fetchSomething(pageParam),
queryFn: ({ pageParam }) => fetchSomething(pageParam),
+ initialPageParam: 0, // New
getNextPageParam: (lastPage) => lastPage.next,
})
The loading
status is now called pending
, and the isLoading
flag has been
renamed to isPending
. This change also applies to mutations.
Additionally, a new isLoading
flag has been added for queries. It is now
defined as the logical AND of isPending
and
isFetching
(isPending && isFetching
). This means that isLoading
behaves the
same as the previous isInitialLoading
. However, since isInitialLoading
is
being phased out, it will be removed in the next major version.
If this blog was helpful, check out our full blog archive.