This article is published in English.
Structuring a TanStack Query Data Layer, from queryOptions to Rollbacks
Build a TanStack Query data layer step by step: shared queryOptions, key factories, selectors, pagination, prefetching, central invalidation and safe optimistic updates.
TanStack Query (formerly React Query) codebases tend to accumulate duplicated keys, forgotten invalidations and spinners everywhere, not because of the library but because of missing structure. This guide builds one feature, a contacts screen, from a single query up to a small data layer with shared options, key factories, automatic invalidation and optimistic deletes, and points out the traps each pattern hides. If you are still deciding where server state should live at all, our comparison of React Query and Redux for server state covers that question first.
The smallest useful query
A query needs a key and a function. This component fetches contacts and handles pending and error states.
import { useQuery } from '@tanstack/react-query';
import { getContacts } from './api/client';
function ContactsTable() {
const { data, isPending, isError, refetch } = useQuery({
queryKey: ['contacts'],
queryFn: getContacts,
});
if (isPending) return <LoadingSpinner />;
if (isError) return <ErrorAlert onRetry={refetch} />;
return <Table data={data} />;
}
The queryKey is not a label. It is the identity of the cache entry: every component that asks for ['contacts'] shares the same data, the same request deduplication and the same background refetching. Almost every pattern below is really about managing keys well.
Sharing query definitions
Wrap repeated queries in a custom hook
When two components need the same data, a custom hook keeps one definition and leaves components with presentation only.
// queries/contacts.ts
export function useContacts() {
return useQuery({
queryKey: ['contacts'],
queryFn: getContacts,
});
}
// Component
function ContactsTable() {
const { data, isPending, isError } = useContacts();
// Clean, focused component logic
}
Prefer queryOptions objects over hooks
A hook can only be called inside a component. A plain options object built with queryOptions can be used by hooks, by prefetchQuery, by getQueryData and by route loaders.
import { queryOptions } from '@tanstack/react-query';
export const contactsQueryOptions = queryOptions({
queryKey: ['contacts'],
queryFn: getContacts,
});
It has two advantages. First, type inference: queryOptions tags the key with the query's data type, so queryClient.getQueryData(contactsQueryOptions.queryKey) comes back typed without manual generics. Second, composability: you can spread the object and override or add fields per call site, as the second component does with select.
// Use directly
function ContactsList() {
const { data } = useQuery(contactsQueryOptions);
return <List items={data} />;
}
// Extend with custom selectors
function ContactsCount() {
const { data } = useQuery({
...contactsQueryOptions,
select: (contacts) => contacts.length,
});
return <Badge count={data} />;
}
Reading data efficiently
Selectors that limit re-renders
select transforms cached data before it reaches the component, and it doubles as a render optimization. It can also live in the shared options:
const contactsQueryOptions = queryOptions({
queryKey: ['contacts'],
queryFn: getContacts,
select: (data) => data.length,
});
The component re-renders based on the selected result. If it selects only the count and the server changes one contact's name, the count is unchanged and the component stays put. On screens where many components read the same large list, that avoids a lot of wasted renders. One caveat: an inline select arrow is a new function on every render, so it re-runs each time; for expensive transforms, define it outside the component or memoize it.
Parameterized queries: every input belongs in the key
A detail page needs a query that depends on an ID. A factory function that returns options keeps that tidy.
export const contactQueryOptions = (contactId: string) =>
queryOptions({
queryKey: ['contacts', contactId],
queryFn: () => getContact(contactId),
});
// Usage
function ContactPage() {
const { id } = useParams();
const { data } = useQuery(contactQueryOptions(id));
return <ContactDetails contact={data} />;
}
The rule that prevents one of the most common production bugs: every variable the query function uses must appear in the key. Leave contactId out and the cache treats all contacts as one entry, so a user may briefly see another person's details. With a router, also consider that id can be undefined; the enabled option lets you hold the query until it exists.
Pagination is a parameterized query plus state
Paging needs nothing special: the page number goes into the key, and changing it in state creates a new query.
export const paginatedContactsOptions = (page: number, pageSize: number) =>
queryOptions({
queryKey: ['contacts', 'paginated', page, pageSize],
queryFn: () => getContacts({ page, pageSize }),
});
function ContactsTable() {
const [page, setPage] = useState(1);
const { data } = useQuery(paginatedContactsOptions(page, 10));
return (
<>
<Table data={data.items} />
<Pagination
currentPage={page}
onNext={() => setPage(p => p + 1)}
/>
</>
);
}
No effect is needed to refetch; a new key simply means a new query. Two refinements matter in practice. On the first render data is undefined, so data.items needs a guard. And on each page change the new key starts empty, so the table flashes; setting placeholderData: keepPreviousData keeps the previous page visible while the next one loads.
Prefetch the page users will ask for next
Combine that with prefetching and the next page is usually ready before the click.
function ContactsTable() {
const [page, setPage] = useState(1);
const queryClient = useQueryClient();
const { data } = useQuery(paginatedContactsOptions(page, 10));
useEffect(() => {
// Silently load the next page in the background
queryClient.prefetchQuery(
paginatedContactsOptions(page + 1, 10)
);
}, [page, queryClient]);
return <Table data={data} />;
}
prefetchQuery fills the cache without subscribing a component. When the user moves to the next page, the query finds fresh data and renders immediately. It also works on hover or before a route loads, but keep it targeted: every prefetch is a real request.
Infinite lists with cursors
For "load more" or infinite scroll, useInfiniteQuery stores a list of pages and tracks the cursor for you. You describe how to find the next cursor in getNextPageParam, and the library does the bookkeeping.
export const infiniteContactsOptions = queryOptions({
queryKey: ['contacts', 'infinite'],
queryFn: ({ pageParam }) => getContacts({ cursor: pageParam }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
function InfiniteContactsList() {
const {
data,
fetchNextPage,
isFetchingNextPage
} = useInfiniteQuery(infiniteContactsOptions);
return (
<>
{data.pages.map(page =>
page.items.map(contact => (
<ContactCard key={contact.id} {...contact} />
))
)}
<button onClick={() => fetchNextPage()}>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
</>
);
}
Note that in TanStack Query v5 the dedicated helper for this shape is infiniteQueryOptions, which gives the infinite fields correct types; check the current docs if queryOptions rejects them. Also use hasNextPage to hide the button once getNextPageParam returns undefined.
Keeping keys consistent with a factory
Hand-written keys drift: one file writes ['contacts', 'list'], another ['contact', 'lists'], and invalidation silently misses. A key factory describes the hierarchy once.
export const contactKeys = {
all: ['contacts'] as const,
lists: () => [...contactKeys.all, 'list'] as const,
list: (filters: ContactFilters) =>
[...contactKeys.lists(), filters] as const,
details: () => [...contactKeys.all, 'detail'] as const,
detail: (id: string) =>
[...contactKeys.details(), id] as const,
};
// Usage in queries
export const contactQueryOptions = (id: string) =>
queryOptions({
queryKey: contactKeys.detail(id),
queryFn: () => getContact(id),
});
// Surgical cache invalidation
queryClient.invalidateQueries({
queryKey: contactKeys.all
}); // Invalidates everything
queryClient.invalidateQueries({
queryKey: contactKeys.lists()
}); // Only list queries
Because keys match by prefix, the hierarchy lets you invalidate broadly or narrowly: contactKeys.all refreshes everything about contacts, while contactKeys.lists() touches only list queries and leaves cached details alone.
Changing data with mutations
A basic mutation hook
Writes go through useMutation. Wrapping it in a hook keeps side effects such as toasts next to the request.
export function useDeleteContact() {
return useMutation({
mutationFn: (contactId: string) => deleteContact(contactId),
onSuccess: () => {
toast.success('Contact deleted successfully');
},
onError: () => {
toast.error('Failed to delete contact');
},
});
}
// Usage in components
function ContactCard({ contact }) {
const { mutate, isPending } = useDeleteContact();
return (
<Card>
<h3>{contact.name}</h3>
<button
onClick={() => mutate(contact.id)}
disabled={isPending}
>
{isPending ? 'Deleting...' : 'Delete'}
</button>
</Card>
);
}
onSuccess, onError and onSettled run at the corresponding stages; isPending makes it easy to disable the button while the request is in flight.
Declaring what a mutation invalidates
After a write, the affected queries must be refetched. Instead of calling invalidateQueries in every hook, a mutation can declare its targets in meta and a single global handler can act on them.
// In your mutation
export function useDeleteContact() {
return useMutation({
mutationFn: (contactId: string) => deleteContact(contactId),
meta: {
invalidates: [contactKeys.all],
},
});
}
// Global setup (one time, in main.tsx)
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
onSettled: async (data, error, variables, context) => {
const meta = context?.meta;
if (meta?.invalidates) {
await Promise.all(
meta.invalidates.map((queryKey) =>
queryClient.invalidateQueries({ queryKey })
)
);
}
},
},
},
});
The idea is sound, but verify the wiring against your version. In the callback signature shown, the fourth argument is the value returned from onMutate, not the mutation, so context?.meta will not find the declared keys. Also, a mutation that defines its own onSettled replaces this default instead of running alongside it. The more robust home for the handler is a MutationCache passed to the QueryClient: its callbacks receive the mutation object (with mutation.meta) and always run in addition to per-mutation callbacks. In TypeScript, typing meta.invalidates requires registering a custom Meta type.
Global error handling
Cross-cutting failures, such as an expired session, belong in one place as well.
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
// Handle authentication globally
if (error.status === 401) {
logout();
navigate('/login');
}
// Handle network errors
if (error.message === 'Network Error') {
toast.error('Check your connection');
}
},
},
},
});
Every mutation then shares the same response to a 401 or a network failure. The same override caveat applies: useDeleteContact above defines its own onError, which replaces this default, so a MutationCache onError is again the dependable choice. Also note that error.status and the 'Network Error' message depend on your HTTP client; the latter is the message Axios produces, while fetch throws a TypeError.
Optimistic updates
Optimistic UI shows the result of a write before the server confirms it. There are two levels.
UI-level: hide items while their deletion is pending
useMutationState exposes in-flight mutations anywhere in the tree. Filtering by key and status yields the IDs currently being deleted, which the list can simply hide.
function useContactsBeingDeleted() {
return useMutationState({
filters: {
mutationKey: ['deleteContact'],
status: 'pending'
},
select: (mutation) => mutation.state.variables,
});
}
function ContactsList() {
const { data: contacts } = useContacts();
const deletingIds = useContactsBeingDeleted();
// Filter out contacts being deleted
const visibleContacts = contacts.filter(
c => !deletingIds.includes(c.id)
);
return <List items={visibleContacts} />;
}
Nothing in the cache changes, so if the request fails the item reappears on its own. This matching relies on the mutation having mutationKey: ['deleteContact'], which the earlier hook did not set; add it or the filter finds nothing.
Cache-level: edit the cache and roll back on failure
The more thorough approach edits the cached list directly, so every component reading it updates at once.
export function useDeleteContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteContact,
onMutate: async (contactId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: contactKeys.lists()
});
// Snapshot current value
const previousContacts = queryClient.getQueryData(
contactKeys.lists()
);
// Optimistically update
queryClient.setQueryData(
contactKeys.lists(),
(old) => old.filter(c => c.id !== contactId)
);
// Return rollback data
return { previousContacts };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousContacts) {
queryClient.setQueryData(
contactKeys.lists(),
context.previousContacts
);
}
},
onSettled: () => {
// Always refetch for consistency
queryClient.invalidateQueries({
queryKey: contactKeys.lists()
});
},
});
}
The sequence matters. cancelQueries stops any in-flight refetch from overwriting the optimistic change. The snapshot is returned from onMutate so that onError can restore it. onSettled refetches whether the request succeeded or failed, so the cache ends up matching the server. Watch the key, too: getQueryData and setQueryData match exactly, so if your lists are cached under contactKeys.list(filters), targeting contactKeys.lists() touches nothing; setQueriesData updates every entry under a prefix. Guard old as well, since the list may not be cached yet.
Suspense for loading states
useSuspenseQuery guarantees data is defined and hands the waiting to a React Suspense boundary.
// Change from useQuery to useSuspenseQuery
function ContactsList() {
const { data } = useSuspenseQuery(contactsQueryOptions);
// No isPending check needed!
return <Table data={data} />;
}
function ContactDetails({ id }) {
const { data } = useSuspenseQuery(contactQueryOptions(id));
return <Details contact={data} />;
}
// Centralized loading UI
function App() {
return (
<Suspense fallback={<AppSkeleton />}>
<ContactsList />
<ContactDetails id="123" />
</Suspense>
);
}
One boundary replaces scattered spinners with a single skeleton. The trade-off is granularity: the boundary waits for its slowest child, so place boundaries where a combined loading state actually makes sense, and prefetch data to avoid request waterfalls.
The patterns combined
Here is the contacts feature with the pieces assembled: a key factory, parameterized options, a delete mutation that declares its invalidation and updates optimistically, and a list that suspends and prefetches.
// queries/contacts.ts
export const contactKeys = {
all: ['contacts'] as const,
lists: () => [...contactKeys.all, 'list'] as const,
list: (filters: Filters) => [...contactKeys.lists(), filters] as const,
detail: (id: string) => [...contactKeys.all, id] as const,
};
export const contactsQueryOptions = (filters: Filters) =>
queryOptions({
queryKey: contactKeys.list(filters),
queryFn: () => getContacts(filters),
});
export function useDeleteContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteContact,
mutationKey: ['deleteContact'],
meta: { invalidates: [contactKeys.all] },
onMutate: async (contactId) => {
await queryClient.cancelQueries({
queryKey: contactKeys.lists()
});
const previous = queryClient.getQueryData(
contactKeys.lists()
);
queryClient.setQueryData(
contactKeys.lists(),
(old) => old?.filter(c => c.id !== contactId)
);
return { previous };
},
onError: (err, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(
contactKeys.lists(),
context.previous
);
}
},
});
}
// components/ContactsList.tsx
function ContactsList() {
const [page, setPage] = useState(1);
const queryClient = useQueryClient();
const { data } = useSuspenseQuery(
contactsQueryOptions({ page, pageSize: 20 })
);
const { mutate: deleteContact } = useDeleteContact();
// Prefetch next page
useEffect(() => {
queryClient.prefetchQuery(
contactsQueryOptions({ page: page + 1, pageSize: 20 })
);
}, [page, queryClient]);
return (
<Table
data={data.items}
onDelete={deleteContact}
pagination={{ page, onChange: setPage }}
/>
);
}
The result is typed end to end, fast, and keeps cache rules in one module. One thing to watch: with useSuspenseQuery, changing page suspends again and shows the fallback on every page change; wrapping setPage in startTransition keeps the current page on screen while the next one loads.
Key takeaways
- Put every query input into its key; that single rule prevents most cache bugs.
- Define queries as
queryOptionsobjects so hooks, prefetching and cache reads share one typed source. - Adopt a key factory early; prefix matching then makes invalidation precise.
- Centralize invalidation and error handling, preferably in
MutationCachecallbacks, so per-mutation callbacks do not silently override them. - Choose UI-level optimism for simple hides and cache-level optimism, with snapshot and rollback, when many components read the data.