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 keys
queryClient.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 queries
function 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.tsx
async 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 query
return queryClient.getQueryData(["todos"])?.find((d) => d.id === todoId);
},
});

Difference Between initialData and placeholderData

source

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 useQuery calls. Example settings that affect the cache entry are queryFn and gcTime, while settings that affect the observer are select and refetchInterval.

  • initialData is persisted to the cache, while placeholderData never 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 provide initialData, React Query waits for staleTime before 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 state
const 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 todo
return 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 initialData is 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 list
queryClient.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

source

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: Infinity
staleTime: 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 fetched
const { data, isPending } = useQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
}
Prefetching comments for an article

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>
))
}
Prefetch outside the suspense query

Separate Client and Server State

source

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 && (
<input
type="text"
value={value}
onChange={(event) => setDraft(event.target.value)}
/>
)}
<span style={{ cursor: "pointer" }} onClick={close}>
&times;
</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]
),
}
}
An alternative to the select option, that only re-renders if the accessed property is changed

Error Handling

  • Use the error property returned from useQuery
const { isError } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
if (isError) {
return <ErrorComponent />
}
  • Use the onError callback (on the query itself or the global QueryCache / 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 logic
throwOnError: (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 cache
return query.state.data === undefined;
},
});
// global configuration
const 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 update
if (typeof query.state.data !== undefined) {
toast.error(`Something went wrong: ${error.message}`)
}
},
}),
})
Throw error if error happens in the initial load, otherwise show a toast 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 }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
There was an error!
<Button onClick={() => resetErrorBoundary()}>Try again</Button>
</div>
)}
>
<Page />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);

Suspense Queries

  • there is no enabled option for useSuspenseQuery, because multiple suspense queries run sequentially so the dependency is expressed by the order of the queries

  • there is no placeholderData for suspense queries, but you can simulate the same pagination example with the startTransition hook.

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>
<button
onClick={() => {
startTransition(() => {
setPage((prev) => prev - 1);
});
}}
>
previous
</button>
<button
onClick={() => {
startTransition(() => {
setPage((prev) => prev + 1);
});
}}
>
next
</button>
</div>
</div>
);
}

Mutations

  • useMutation is imperative, while useQuery is declarative. You need to call the returned mutate function yourself to trigger the mutation.

  • mutationKey is not required for useMutation and has nothing to do with query keys. Setting it to the same value as a query key does not revalidate that query; use queryClient.invalidateQueries instead.

  • When you need to share the state of a mutation across components, set mutationKey and use useMutationState to 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}
<button
onClick={() => {
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

source

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 useMutation callbacks

  • 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 list
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["todos", "list"],
});
},
});
// in the component
const 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 provided
mutation.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

source

Optimistic updates can be implemented in two ways:

  • after mutationFn is called, display variables (the input supplied to mutationFn) from useMutation or useMutationState. 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 finished
onSettled: async () => {
return await queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
const { isPending, submittedAt, variables } = addTodoMutation
return <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 onMutate callback to manipulate the cache. The gist is that we call setQueryData with the new data and, if the mutation fails, revert the cache to its previous state. The value returned by onMutate can be accessed via the context argument in onError and onSettled.
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 value
const previousTodos = queryClient.getQueryData(["todos"]);
// Optimistically update to the new value
queryClient.setQueryData(["todos"], (old) => [...old, newTodo]);
// Return a context object with the snapshotted value
return { previousTodos };
},
// If the mutation fails,
// use the context returned from onMutate to roll back
onError: (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 update
const 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 requests
return 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,
})
two queries running in parallel

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.tsx
function 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.length
return (
<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: keepPreviousData to prevent loading spinners when the page changes

  • use isPlaceholderData to provide loading feedback and disable pagination buttons

  • set up a useEffect to 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>
<button
onClick={() => setPage((p) => p - 1)}
disabled={isPlaceholderData || page === 1}
>
Previous
</button>
<span>Page {page}</span>
<button
disabled={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) => (
<div
key={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. useInfiniteQuery returns data as 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 initialPageParam to give the fetcher a starting point, and use getNextPageParam to provide the next cursor. How you get the next cursor varies, but getNextPageParam has access to all the current data.

  • fetchNextPage triggers the fetcher with the next cursor. When to call fetchNextPage is up to you, e.g., using an intersection observer

  • if getNextPageParam returns undefined or null, hasNextPage is set to false and you can conditionally hide the trigger

  • infinite queries can be bidirectional (e.g. chat messages); getPreviousPageParam can 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 data
getPreviousPageParam: (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 cache
maxPages: 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: use isPending rather than isLoading to show a loading spinner, because isLoading=false when the query is paused.
const { status, fetchStatus } = useProjects()
// isLoading is derived from status and fetchStatus
const 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.

    • refetchOnReconnect defaults to false in 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 (
<PersistQueryClientProvider
client={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 mutations
return queryClient.resumePausedMutations();
},
}}
></PersistQueryClientProvider>
);
}
// later in components
useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
meta: { persist: true },
});

Notes for the code above:

  • defaultShouldDehydrateQuery is a helper function that only persists successful queries and respects React Query’s other default persist logic; defaultShouldDehydrateMutation does the same for mutations

  • set gcTime equal to or greater than maxAge to 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 ...
}

With Next.js

With Remix