Customizing the Defaults
- Global configuration in
new QueryClient()
const queryClient = new QueryClient({defaultOptions: {queries: {staleTime: 5000,},},});
- Control a subset of queries with
setQueryDefaults
// set options for a subset of queries via fuzzy matching on the query keysqueryClient.setQueryDefaults(["todos", "detail"], { staleTime: 1000 });
Suppose we have the following keys
["todos", "detail1", 1] // match["todos", "detail2", 2] // match["todos", "list"] // not affected
- For a specific query, set the options directly in
useQuery
Additionally, all options in useQuery (except for queryKey) can have
a default value, even the query function.
queryClient.setQueryDefaults(["todos"], {staleTime: 5000,queryFn: ({ queryKey }) => fetchTodos(queryKey),});// can omit the query function in the following queriesfunction useTodos() {return useQuery({queryKey: ["todos"],});}function useCompletedTodos() {return useQuery({queryKey: ["todos", "completed"],staleTime: 1000,});}
Validating Query Response
useQuery({queryKey: ['todos'],queryFn: () => {const response = await fetch('/todos')const data = await response.json()return TodoSchema.parse(data)}})
The benefits of using zod:
-
Saves memory in the cache by stripping unspecified fields
-
Throws errors when data doesn’t match
Pre-filling with initialData
We can use initialData to pre-fill the query cache in two ways:
- pass results from server components to client components to save the first fetch
// server.tsxasync function ServerComponent() {const data = await getData();return <ClientComponent data={data} />;}// client.tsx"use client"function ClientComponent({ data }) {const { data } = useQuery('key', fetcher, { initialData: data });}
- when a query is a subset of another query (e.g., fetching the completed todos is a subset of fetching all todos), we can use the query data from the parent query to pre-fill the child query, source
export const useTodosQuery = (state: State) =>useQuery({queryKey: ["todos", state],queryFn: () => fetchTodos(state),initialData: () => {const allTodos = queryClient.getQueryData<Todos>(["todos", "all"]);const filteredData =allTodos?.filter((todo) => todo.state === state) ?? [];return filteredData.length > 0 ? filteredData : undefined;},});
Another example of pre-filling an id-based query
const result = useQuery({queryKey: ["todo", todoId],queryFn: () => fetch("/todos"),initialData: () => {// Use a todo from the 'todos' query as the initial data for this todo queryreturn queryClient.getQueryData(["todos"])?.find((d) => d.id === todoId);},});
Difference Between initialData and placeholderData
initialData works on the cache level, while placeholderData works on
the observer level.
-
caches are identified by query keys, while observers are subscriptions created by
useQuerycalls. Example settings that affect the cache entry arequeryFnandgcTime, while settings that affect the observer areselectandrefetchInterval. -
initialDatais persisted to the cache, whileplaceholderDatanever is. I like to see it as “fake-it-till-you-make-it” data. It’s “not real”. -
refetch is triggered immediately regardless of
placeholderData, because it’s not “real”. But if you provideinitialData, React Query waits forstaleTimebefore refetching.
Conditional initialData
Only set initial data if the available data was updated recently.
const result = useQuery({queryKey: ["todo", todoId],queryFn: () => fetch(`/todos/${todoId}`),initialData: () => {// Get the query stateconst state = queryClient.getQueryState(["todos"]);// If the query exists and has data that is no older than 10 seconds...if (state && Date.now() - state.dataUpdatedAt <= 10 * 1000) {// return the individual todoreturn state.data.find((d) => d.id === todoId);}// Otherwise, return undefined and let it fetch from a hard loading state!},});
Only set initial data if it’s the first page
const [page, setPage] = React.useState(0);const { data } = useQuery({queryKey: ["todos", page],queryFn: () => fetchTodos(page),initialData: page === 0 ? initialDataForPageZero : undefined,staleTime: 5 * 1000,});
Seeding with Pushing or Pulling
Setting initialData is a form of seeding, often used when we have a
query that fetches a list of items as well as queries that fetch
individual items. There are two common patterns:
- Pulling: when
initialDatais needed for a single item, search for it in the list; if it’s not found, fetch it from the server
useQuery({queryKey: ["todos", "detail", id],queryFn: () => fetchTodo(id),initialData: () => {return queryClient.getQueryData(["todos", "list"])?.find((todo) => todo.id === id);},initialDataUpdatedAt: () =>// ⬇️ get the last fetch time of the listqueryClient.getQueryState(["todos", "list"])?.dataUpdatedAt,});
Pulling is the recommended approach because it seeds “just in time”.
The only downside is that you need an extra initialDataUpdatedAt to
make sure React Query respects the stale time.
- Pushing: when the list query resolves, seed each entry into its
individual query with
queryClient.setQueryData()
const useTodos = () => {const queryClient = useQueryClient();return useQuery({queryKey: ["todos", "list"],queryFn: async () => {const todos = await fetchTodos();todos.forEach((todo) => {queryClient.setQueryData(["todos", "detail", todo.id], todo);});return todos;},});};
With pushing, staleTime is automatically respected because the seed
happens at the same time as the list fetch. But this might create
unnecessary cache entries and the pushed data might be garbage collected
too early.
Prefetching
Seeding is useful when you already have the exact data future queries need. With relational data, e.g. a feed and its comments, fetching the feed doesn’t return the comments, but we can prefetch the comments in parallel with the feed.
function Article({ id }) {const { data: articleData, isPending } = useQuery({queryKey: ['article', id],queryFn: getArticleById,})useQuery({queryKey: ['article-comments', id],queryFn: getArticleCommentsById,// Optional optimization to avoid rerenders when this query changes:notifyOnChangeProps: [],// Only prefetch is the data is older than 5 years old, to disable prefetch when there is data, set staleTime: InfinitystaleTime: 5000})if (isPending) {return 'Loading article...'}return (<><ArticleHeader articleData={articleData} /><ArticleBody articleData={articleData} /><Comments id={id} /></>)}function Comments({ id }) {// this query will be prefetched when the article query is fetchedconst { data, isPending } = useQuery({queryKey: ['article-comments', id],queryFn: getArticleCommentsById,})}
Another way is to prefetch inside the query function. This makes sense
if comments are very likely to be needed every time an article is
fetched. For this, we’ll use queryClient.prefetchQuery:
const queryClient = useQueryClient();const { data: articleData, isPending } = useQuery({queryKey: ["article", id],queryFn: (...args) => {queryClient.prefetchQuery({queryKey: ["article-comments", id],queryFn: getArticleCommentsById,});return getArticleById(...args);},});
If the primary query is a suspense query, don’t put the prefetch query inside the same component: that component is unmounted before the suspense query resolves, so the prefetch query only kicks off after the suspense query resolves. Instead, prefetch “one level up” in the parent component.
function App() {usePrefetchQuery({queryKey: ['article-comments', id],queryFn: getArticleCommentsById,notifyOnChangeProps: [],})return (<Suspense fallback="Loading articles..."><Articles /></Suspense>)}function Articles() {const { data: articles } = useSuspenseQuery({queryKey: ['articles'],queryFn: (...args) => {return getArticles(...args)},})return articles.map((article) => (<div key={articleData.id}><ArticleHeader article={article} /><ArticleBody article={article} /></div>))}
Separate Client and Server State
Use a query to pre-fill some user inputs. If the input is not touched, keep the query running; if it is, stop the query and use the user input.
In the following example, the input is bound to value, which is either
the draft state or the query data. As soon as the user starts typing,
the draft state takes precedence and enabled becomes false.
const useRandomValue = () => {const [draft, setDraft] = React.useState(undefined);const { data, ...queryInfo } = useQuery("random",async () => {await sleep(1000);return Promise.resolve(String(Math.random()));},{enabled: typeof draft === "undefined",});return {value: draft ?? data,setDraft,queryInfo,};};function Modal({ close }) {const {value,setDraft,queryInfo: { isLoading, error },} = useRandomValue();return (<div>{isLoading && "Loading..."}{error && "error"}{value !== undefined && (<inputtype="text"value={value}onChange={(event) => setDraft(event.target.value)}/>)}<span style={{ cursor: "pointer" }} onClick={close}>×</span></div>);}
Data Transformations
You can use the select option to select a subset of the data that your
component should subscribe to. This is useful for highly optimized data
transformations or to avoid unnecessary re-renders.
export const useTodosQuery = (select) =>useQuery({queryKey: ["todos"],queryFn: fetchTodos,select,});export const useTodosCount = () => useTodosQuery((data) => data.length);export const useTodo = (id) =>useTodosQuery((data) => data.find((todo) => todo.id === id));
A component using the useTodoCount custom hook will only re-render if
the length of the todos changes. It will not re-render if e.g. the
name of a todo has changed.
In contrast to transforming the data directly after useTodo, which
runs during every re-render or when the query data changes, the select
option is a more efficient way to transform data.
export const useTodosQuery = () => {const queryInfo = useQuery({queryKey: ['todos'],queryFn: fetchTodos})return {...queryInfo,data: React.useMemo(() => queryInfo.data?.length,[queryInfo.data]),}}
select option, that only re-renders if the accessed property is changedError Handling
- Use the
errorproperty returned fromuseQuery
const { isError } = useQuery({queryKey: ['todos'],queryFn: fetchTodos,})if (isError) {return <ErrorComponent />}
- Use the
onErrorcallback (on the query itself or the globalQueryCache/MutationCache)
const useTodos = () =>useQuery({queryKey: ["todos"],queryFn: fetchTodos,onError: (error) => toast.error(`Something went wrong: ${error.message}`),});
- Use Error Boundaries
Set throwOnError to true to throw an error when the query fails,
which can be caught by an error boundary.
const todos = useQuery({queryKey: ['todos'],queryFn: fetchTodos,throwOnError: true,})// custom error logicthrowOnError: (error) => error.response?.status >= 500,
For more granular control over error handling, pass a function to
throwOnError. The error is only thrown when the function returns
true:
useQuery({queryKey: ["todos"],throwOnError: (error, query) => {// only throw if no cache exists// fail silently if we have data in the cachereturn query.state.data === undefined;},});// global configurationconst queryClient = new QueryClient({defaultOptions: {queries: {throwOnError: (error, query) => {return query.state.data === undefined;},},},});
Pattern: handle refetch errors globally with toast messages and handle initial load errors with error boundaries
const queryClient = new QueryClient({defaultOptions: {queries: {throwOnError: (error, query) => {return typeof query.state.data === "undefined"}}}queryCache: new QueryCache({onError: (error, query) => {// 🎉 only show error toasts if we already have data in the cache// which indicates a failed background updateif (typeof query.state.data !== undefined) {toast.error(`Something went wrong: ${error.message}`)}},}),})
Reset Error Boundaries
Query errors can be reset with the QueryErrorResetBoundary component
or with the useQueryErrorResetBoundary hook.
The component resets any query errors within its boundaries:
import { QueryErrorResetBoundary } from "@tanstack/react-query";import { ErrorBoundary } from "react-error-boundary";const App = () => (<QueryErrorResetBoundary>{({ reset }) => (<ErrorBoundaryonReset={reset}fallbackRender={({ resetErrorBoundary }) => (<div>There was an error!<Button onClick={() => resetErrorBoundary()}>Try again</Button></div>)}><Page /></ErrorBoundary>)}</QueryErrorResetBoundary>);
Suspense Queries
-
there is no
enabledoption foruseSuspenseQuery, because multiple suspense queries run sequentially so the dependency is expressed by the order of the queries -
there is no
placeholderDatafor suspense queries, but you can simulate the same pagination example with thestartTransitionhook.
function App() {<Suspense fallback={<div>loading...</div>}><Todos /></Suspense>;}function Todos() {const todos = useSuspenseQuery({queryKey: ["todos", page],queryFn: () => fetchTodos(page),});const [isPreviousData, startTransition] = useTransition();return (<div><ul style={{ opacity: isPreviousData ? 0.5 : 1 }}>{todos.map((todo) => (<li key={todo.id}>{todo.title}</li>))}</ul><div><buttononClick={() => {startTransition(() => {setPage((prev) => prev - 1);});}}>previous</button><buttononClick={() => {startTransition(() => {setPage((prev) => prev + 1);});}}>next</button></div></div>);}
Mutations
-
useMutationis imperative, whileuseQueryis declarative. You need to call the returnedmutatefunction yourself to trigger the mutation. -
mutationKeyis not required foruseMutationand has nothing to do with query keys. Setting it to the same value as a query key does not revalidate that query; usequeryClient.invalidateQueriesinstead. -
When you need to share the state of a mutation across components, set
mutationKeyand useuseMutationStateto access the state.
function App() {const mutation = useMutation({mutationKey: ["todos"],mutationFn: (newTodo) => {return axios.post('/todos', newTodo)},})return (<div>{mutation.isPending ? ('Adding todo...') : (<>{mutation.isError ? (<div>An error occurred: {mutation.error.message}</div>) : null}{mutation.isSuccess ? <div>Todo added!</div> : null}<buttononClick={() => {mutation.mutate({ id: new Date(), title: 'Do Laundry' })}}>Create Todo</button></>)}</div>)}const data = useMutationState(filters: { mutationKey: ['todos'] },select: (mutation) => mutation.state.data,)// get the latest data from the mutation (data is an array of all data from past mutations)const latest = data[data.length - 1]
Mutation Callbacks
Callbacks such as onSuccess, onError and onSettled can be set on
useMutation as well as on mutate itself. The callbacks on
useMutation fire before those on mutate, and the callbacks on
mutate might not fire at all if the component unmounts before the
mutation finishes.
Rules of thumb for separating concerns between callbacks in
useMutation and mutate:
-
Do absolutely necessary logic (such as query invalidation) in
useMutationcallbacks -
Do UI-related things like redirects or showing toast notifications in mutate callbacks. If the user navigated away from the current screen before the mutation finished, those will purposefully not fire.
const useUpdateTodo = () =>useMutation({mutationFn: updateTodo,// ✅ always invalidate the todo listonSuccess: () => {queryClient.invalidateQueries({queryKey: ["todos", "list"],});},});// in the componentconst updateTodo = useUpdateTodo();updateTodo.mutate({ title: "newTitle" },// ✅ only redirect if we're still on the detail page// when the mutation finishes{ onSuccess: () => history.push("/todos") });
Invalidation After Mutation
The simplest form of invalidation is to invalidate a single query after a mutation.
useMutation({mutationFn: updateTodo,onSuccess: () => {queryClient.invalidateQueries({queryKey: ["todos", "list"],});},});
To make things more declarative, we can set a global onSuccess
callback on MutationCache to search for a meta property in the
finished mutation and invalidate any query that matches it.
import { matchQuery } from "@tanstack/react-query";const queryClient = new QueryClient({mutationCache: new MutationCache({onSuccess: (_data, _variables, _context, mutation) => {queryClient.invalidateQueries({queryKey,predicate: (query) =>// invalidate all matching tags at once// or everything if no meta is providedmutation.meta?.invalidates?.some((queryKey) =>matchQuery({ queryKey }, query)) ?? true,});},}),});// usage:useMutation({mutationFn: mutateFn,meta: {invalidates: [["issues"], ["labels"]],},});
declare module "@tanstack/react-query" {interface Register {mutationMeta: {invalidates?: Array<QueryKey>;};}}
Optimistic Updates
Optimistic updates can be implemented in two ways:
- after
mutationFnis called, displayvariables(the input supplied tomutationFn) fromuseMutationoruseMutationState. This manipulates the UI directly
const addTodoMutation = useMutation({mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),// make sure to _return_ the Promise from the query invalidation// so that the mutation stays in `pending` state until the refetch is finishedonSettled: async () => {return await queryClient.invalidateQueries({ queryKey: ['todos'] })},})const { isPending, submittedAt, variables } = addTodoMutationreturn <ul>{todoQuery.items.map((todo) => (<li key={todo.id}>{todo.text}</li>))}{isPending && <li style={{ opacity: 0.5 }} key={submittedAt}>{variables}</li>}</ul>
Because we are awaiting invalidateQueries, isPending will only be
false when the invalidation is finished. So when the opacity item is
removed, we will see the most up-to-date data.
This approach can be problematic if you click on multiple checkboxes in
a row, because there is going to be a gap between the
invalidateQueries calls. For example, if the second todo we clicked
was originally checked, it is now unchecked. If the first invalidation
finishes now, it will set the second todo back to checked, because
that’s the server state at that point. It fixes itself after the last
mutation succeeds and the queries are invalidated, but it’s not a great
experience for the user.
The root of this problem is that we don’t change the cache until the mutation finishes. We can fix this by updating the cache as soon as the mutation is called and reverting it if the mutation fails.
- Second method: use the
onMutatecallback to manipulate the cache. The gist is that we callsetQueryDatawith the new data and, if the mutation fails, revert the cache to its previous state. The value returned byonMutatecan be accessed via thecontextargument inonErrorandonSettled.
const queryClient = useQueryClient();useMutation({mutationFn: updateTodo,// When mutate is called:onMutate: async (newTodo) => {// Cancel any outgoing refetches// (so they don't overwrite our optimistic update)await queryClient.cancelQueries({ queryKey: ["todos"] });// Snapshot the previous valueconst previousTodos = queryClient.getQueryData(["todos"]);// Optimistically update to the new valuequeryClient.setQueryData(["todos"], (old) => [...old, newTodo]);// Return a context object with the snapshotted valuereturn { previousTodos };},// If the mutation fails,// use the context returned from onMutate to roll backonError: (err, newTodo, context) => {queryClient.setQueryData(["todos"], context.previousTodos);},// Always refetch after error or success:onSettled: () => {queryClient.invalidateQueries({ queryKey: ["todos"] });},});
You can also return a function from onMutate to serve as a rollback:
useMutation({onMutate: () => {// ... do the optimistic updateconst snapshot = queryClient.getQueryData(["todos"]);return () => {queryClient.setQueryData(["todos"], snapshot);};},onError: (error, variables, rollback) => {rollback?.();},});
Query and Mutation Cancellation
Queries can be canceled manually with
queryClient.cancelQueries({queryKey}).
Alternatively, each queryFn is given a signal parameter, which comes
from an AbortController that React Query creates under the hood, so
queries can be cancelled if your queryFn understands that signal. For
example, when making queries based on a search input, it helps to cancel
all previous queries except the latest one.
useQuery({queryKey: ["todos", search],queryFn: async ({ signal }) => {const response = await fetch(`/todos?search=${search}`, { signal });using the signal from queryContext to cancel ongoing requestsreturn response.json();},});
Cancellation does not work when working with Suspense hooks:
useSuspenseQuery, useSuspenseQueries and useSuspenseInfiniteQuery.
Typed Query Options
While the useQuery generic is automatically typed by queryFn,
methods such as queryClient.getQueryData({ queryKey }) do not have
access to the query function type. To solve this, we can co-locate the
queryKey and queryFn using the queryOptions helper and use it in
both useQuery and getQueryData.
import { queryOptions } from "@tanstack/react-query";const fetchGroups = (): Promise<Group[]> =>axios.get("/groups").then((response) => response.data);function groupOptions() {return queryOptions({queryKey: ["groups"],queryFn: fetchGroups,staleTime: 5 * 1000,});}useQuery(groupOptions());const data = queryClient.getQueryData(groupOptions().queryKey);// ^? const data: Group[] | undefined
Parallel Queries
Multiple useQuery observers are already running in parallel by
default.
useQuery({queryKey: ['todos'],queryFn: fetchTodos,})useQuery({queryKey: ['users'],queryFn: fetchUsers,})
But there are cases where the queries are dynamic and can’t be laid out
statically. In such cases, we can use useQueries to dynamically
generate multiple query options objects:
function App({ users }) {const userQueries = useQueries({queries: users.map((user) => {return {queryKey: ["user", user.id],queryFn: () => fetchUserById(user.id),};}),});}
The return value of useQueries is an array of individual useQuery
results, and you can process the array however you want.
const areAnyPending = userQueries.some((query) => query.status === "pending");
If queries in useQueries is an empty array, it won’t do anything.
This is useful to implement dependent queries similar to the enabled
option.
In the following example, we fetch a list of repos and then fetch the
issues for each repo. We use useQueries to dynamically generate the
issue queries.
function useRepos() {return useQuery({queryKey: ['repos'],queryFn: fetchRepos})}function useIssues(repos) {return useQueries({queries: repos?.map((repo) => ({queryKey: ['repos', repo.name, 'issues'],queryFn: async () => {const issues = await fetchIssues(repo.name)return { repo: repo.name, issues }}})) ?? []})}// component.tsxfunction App() {const repos = useRepos()const issues = useIssues(repos.data)return {repos.isSuccess ?<ul>{repos.data.map((repo) => {const repoIssues = issues.find(query => query.data?.repo === repo.name)const length = repoIssues?.data.issues.lengthreturn (<li key={repo.id}>{repo.name}{repoIssues? ` (${length === 30 ? "30+" : length} issues)`: null}</li>)})}</ul>: null}}
An alternative to dynamic useQueries is creating a separate component
for each item and using useQuery inside it. The downside is that it’s
hard to derive values from all the queries, e.g. the total number of
issues. With useQueries, we can just loop through the queries array.
const repos = useRepos();const issues = useIssues(repos.data);const totalIssues = issues.map(({ data }) => data?.issues.length ?? 0).reduce((a, b) => a + b, 0);
useQueries provides a combine argument for this use case: whatever
combine returns becomes the result of the useQueries hook.
function useIssues(repos) {return useQueries({queries:repos?.map((repo) => ({queryKey: ["repos", repo.name, "issues"],queryFn: async () => {const issues = await fetchIssues(repo.name);return { repo: repo.name, issues };},})) ?? [],combine: (issues) => {const totalIssues = issues.map(({ data }) => data?.issues.length ?? 0).reduce((a, b) => a + b, 0);return { issues, totalIssues };},});}const { issues, totalIssues } = useIssues(repos.data);
combine is useful even when you don’t need to derive an aggregation.
You can simply use it to reshape the return values so it fits your
component’s needs.
function useRepoAndIssues({ name }) {return useQueries({queries: [{queryKey: ["repos", name],queryFn: async () => fetchRepo(name),},{queryKey: ["repos", name, "issues"],queryFn: async () => fetchIssues(name),},],combine: (results) => {const isPending = results.some((query) => query.status === "pending");const isError = results.some((query) => query.status === "error");return {repo: results[0].data,issues: results[1].data,isPending,isError,};},});}const { repo, issues, isPending, isError } = useRepoAndIssues({ name });
Pagination
-
use
placeholderData: keepPreviousDatato prevent loading spinners when the page changes -
use
isPlaceholderDatato provide loading feedback and disable pagination buttons -
set up a
useEffectto prefetch the data for the next page whenever the page changes
function useRepos(sort, page) {const queryClient = useQueryClient();React.useEffect(() => {queryClient.prefetchQuery(getReposQueryOptions(sort, page + 1));}, [sort, page, queryClient]);return useQuery({...getReposQueryOptions(sort, page),placeholderData: (previousData) => previousData,});}function RepoList({ sort, page, setPage }) {const { data, status, isPlaceholderData } = useRepos(sort, page);if (status === "pending") {return <div>...</div>;}if (status === "error") {return <div>There was an error fetching the repos.</div>;}return (<div>// !mark(1:1)<ul style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>{data.map((repo) => (<li key={repo.id}>{repo.full_name}</li>))}</ul><div><buttononClick={() => setPage((p) => p - 1)}disabled={isPlaceholderData || page === 1}>Previous</button><span>Page {page}</span><buttondisabled={isPlaceholderData || data?.length < PAGE_SIZE}onClick={() => setPage((p) => p + 1)}>Next</button></div></div>);}
Infinite Queries
getProjects is a mock API that returns an object
// nextId and previousId are cursors to fetch the next and previous page{data, nextId, previousId;}
Example:
import { useEffect } from "react";import { useProjects } from "lib/features/project/queries";const fetchProjects = async (cursor: number) => {await delay(1000);return await getProjects(cursor);};export default function Infinite() {const { data, fetchNextPage, isFetchingNextPage, hasNextPage, isLoading } =useInfiniteQuery({queryKey: projectKeys.list(),queryFn: ({ pageParam }) => fetchProjects(pageParam),initialPageParam: 0,getNextPageParam: (lastPage) => lastPage.nextId,getPreviousPageParam: (firstPage) => firstPage.previousId,});const shouldFetch = !isFetchingNextPage && hasNextPage;useEffect(() => {const observer = new IntersectionObserver((entries) => {if (entries.some((entry) => entry.isIntersecting) && shouldFetch) {fetchNextPage();}});const trigger = document.getElementById("fetch-trigger");if (trigger) {observer.observe(trigger);}return () => {if (trigger) {observer.unobserve(trigger);}};}, [fetchNextPage, isFetchingNextPage, shouldFetch]);return (<div>{isLoading ? <div>Loading initial data</div> : null}{data?.pages.flatMap((p) => p.data).map((project) => (<divkey={project.id}className="flex h-[50vh] items-center justify-center border"><h2>{project.name}</h2></div>))}// !mark(1:1)<div id="fetch-trigger" />{isFetchingNextPage ? <div>Loading next page...</div> : null}{data && !hasNextPage ? <div>No more projects to load</div> : null}</div>);}
- Infinite queries are about changing a cursor and passing it to the
fetch function.
useInfiniteQueryreturnsdataas a 2D array of all pages
[[1, 2, 3], // data for page 1[4, 5, 6], // data for page 2[7, 8, 9], // data for page 3];
-
set
initialPageParamto give the fetcher a starting point, and usegetNextPageParamto provide the next cursor. How you get the next cursor varies, butgetNextPageParamhas access to all the current data. -
fetchNextPagetriggers the fetcher with the next cursor. When to callfetchNextPageis up to you, e.g., using an intersection observer -
if
getNextPageParamreturns undefined or null,hasNextPageis set to false and you can conditionally hide the trigger -
infinite queries can be bidirectional (e.g. chat messages);
getPreviousPageParamcan be used to fetch the previous page. If the API doesn’t return a cursor, e.g., it’s built for pagination, we can create the cursor ourselves
return useInfiniteQuery({queryKey: ['projects'],queryFn: fetchProjects,initialPageParam: 0,getNextPageParam: (lastPage, allPages, lastPageParam) => {if (lastPage.length === 0) {return undefined}return lastPageParam + 1},// firstPage is the current topmost page datagetPreviousPageParam: (firstPage, allPages, firstPageParam) => {if (firstPageParam <= 1) {return undefined}return firstPageParam - 1},})
-
we use a single query key for all pages, so all pages are treated as a single cache entry and revalidated together. This can become a problem:
-
the cache entry can become very large
-
when the query becomes stale and needs to be refetched, each group is fetched sequentially, starting from the first one. This is the only way to ensure we have the most up-to-date data for all pages.
-
Set maxPages to limit the number of pages that are kept in the cache.
useInfiniteQuery({queryKey: ["projects"],queryFn: fetchProjects,initialPageParam: 0,getNextPageParam: (lastPage, pages) => lastPage.nextCursor,getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,// only allow 5 pages to be kept in the cachemaxPages: 5,});
Offline Support with networkMode
Both useQuery and useMutation have a networkMode option with three
values:
networkMode = 'online': the default mode. Queries and mutations rely on the network. If we go offline, they go into the paused state automatically. A related note: useisPendingrather thanisLoadingto show a loading spinner, becauseisLoading=falsewhen the query is paused.
const { status, fetchStatus } = useProjects()// isLoading is derived from status and fetchStatusconst isLoading = status === 'pending' || fetchStatus === 'fetching'// isLoading will be false when fetchStatus is 'paused'if (isLoading) {return <div>Loading...</div>}
-
networkMode = 'always': this mode means that your queries and mutations don’t need network access.useQuery({queryKey: ["todos"],queryFn: () => Promise.resolve([{ id: 1, text: "Do Laundry" }]),networkMode: "always",});-
Queries will never be paused because you have no network connection.
-
Retries will also not pause - your Query will go to error state if it fails.
-
refetchOnReconnectdefaults tofalsein this mode, because reconnecting to the network is not a good indicator anymore that stale queries should be refetched. You can still turn it on if you want.
-
-
networkMode = 'offlineFirst': the first request will always be made (possibly without network connection), and if that fails, retries will be paused. This mode is useful if you’re using an additional caching layer like the browser cache on top of React Query. For example, the GitHub API sets the browser cache as
cache-control: public, max-age=60, s-maxage=60
which means that for the next 60 seconds, if you request that resource again, the response will come from the browser cache.
In this case, we want React Query to run even when we are offline, because chances are the browser cache has the data we need. If there’s a cache miss, you’ll likely get a network error, after which React Query pauses the retries and puts your query into the paused state. It’s the best of both worlds.
Offline Mutations
Everything about networkMode applies to mutations as well. Note that
we often invalidate the cache in the onSettled callback of a mutation.
useMutation({onSettled: () => {queryClient.invalidateQueries({ queryKey: ["todos"] });},});
When we go offline in the middle of a mutation, the mutation is paused,
and onSettled will be invoked after we go back online and the mutation
finishes. In contrast, onMutate fires before the mutation function so
that our optimistic updates in there can be seen regardless of the
network status.
One problem is that if multiple paused mutations resume after we go back online, we run multiple invalidations, which might cause the UI to update multiple times. To avoid this, we can check the number of ongoing mutations and only invalidate the cache if it’s the last one.
{onSettled;() => {if (queryClient.isMutating({ mutationKey: ["todos"] }) === 1) {return queryClient.invalidateQueries({ queryKey: ["todos"] });}};}
Persistence
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";import {defaultShouldDehydrateMutation,defaultShouldDehydrateQuery,QueryClient,useIsRestoring,} from "@tanstack/react-query";import {PersistQueryClientProvider,removeOldestQuery,} from "@tanstack/react-query-persist-client";const persister = createSyncStoragePersister({storage: window.localStorage,retry: removeOldestQuery,});const queryClient = new QueryClient({defaultOptions: {queries: {gcTime: 1000 * 60 * 60,},},});queryClient.setMutationDefaults(["posts", "add"], {mutationFn: addPost,});function App() {const isRestoring = useIsRestoring();if (isRestoring) {return <div>Restoring...</div>;}return (<PersistQueryClientProviderclient={queryClient}persistOptions={{persister,maxAge: 1000 * 60 * 60,dehydrateOptions: {shouldDehydrateQuery: (query) =>defaultShouldDehydrateQuery(query) && query.meta?.persist === true,shouldDehydrateMutation: (mutation) =>defaultShouldDehydrateMutation(mutation) &&mutation.meta?.persist === true,},onSuccess: () => {resume mutationsreturn queryClient.resumePausedMutations();},}}></PersistQueryClientProvider>);}// later in componentsuseQuery({queryKey: ["todos"],queryFn: fetchTodos,meta: { persist: true },});
Notes for the code above:
-
defaultShouldDehydrateQueryis a helper function that only persists successful queries and respects React Query’s other default persist logic;defaultShouldDehydrateMutationdoes the same for mutations -
set
gcTimeequal to or greater thanmaxAgeto avoid queries being garbage collected and removed from the storage too early -
mutations and their inputs can be saved to storage as well. We also set the default mutation function for the mutation key, so when restoring mutations by key, React Query doesn’t need to look up the mutation function
As an experimental feature, we can now set persist per query
import { experimental_createPersister } from "@tanstack/react-query-persist-client";useQuery({queryKey: ["todos"],queryFn: fetchTodos,persister: experimental_createPersister({storage: localStorage,// ..other options}),});
The default options for the persister are
{prefix = 'tanstack-query',maxAge = 1000 * 60 * 60 * 24,serialize = JSON.stringify,deserialize = JSON.parse,}
Using with SSR
https://tanstack.com/query/latest/docs/framework/react/guides/ssr
An example of RSC streaming and prefetching data on the server (don’t await the prefetch)
export default async function Home() {const queryClient = new QueryClient({defaultOptions: {dehydrate: {shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) && query.state.status === "pending",}}})queryClient.prefetchQuery({queryKey: ['todos'],queryFn: fetchTodos,staleTime: 1000 * 10})return <main><header /><HydrationBoundary state={dehydrate(queryClient)}><Suspense fallback={<div>loading...</div>}><Todos /></Suspense></HydrationBoundary></main>}function Todos() {const todos = useSuspenseQuery({queryKey: ['todos'],queryFn: fetchTodos,staleTime: 1000 * 10})return ...}