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.