TanStack Query (formerly React Query) is an asynchronous state management library for React that fetches, caches, deduplicates, and synchronizes server data, replacing the hand-rolled useEffect-plus-useState pattern most data-fetching bugs hide in. Its core insight is that server state is fundamentally different from client state: it lives remotely, can go stale the moment you fetch it, and is shared across components — so it needs caching, background refetching, and garbage collection rather than a global store. You describe a query with a unique queryKey and a Promise-returning queryFn, and the library manages loading and error flags, retries, request deduplication, and cache lifecycle through two timers: staleTime, which controls when data is refetched, and gcTime, which controls when unused cache entries are removed. This guide goes from that mental model to production patterns: key factories, mutations and invalidation, optimistic updates with rollback, infinite queries, SSR hydration in Next.js, and React Native.
Why Is Server State Not Client State?
The single most important idea behind TanStack Query is that server state is fundamentally different from client state. Client state is owned by your app: the current theme, whether a modal is open, the value of a form input. It is synchronous, always up to date, and only you can change it. Server state is a completely different animal.
What makes server state hard:
- It is persisted remotely and you only ever hold a snapshot of it
- It can be changed by other users or processes without your knowledge, so it becomes stale
- Fetching and updating it is asynchronous and can fail
- It is shared: many components may need the same data at the same time
- It needs caching, deduplication, and garbage collection to be efficient
Tools like Redux, Zustand, or the Context API are excellent at client state but are the wrong tool for server state, because they force you to reimplement caching and synchronization by hand. TanStack Query is a dedicated async server-state manager. You describe how to fetch data, and it owns the cache, staleness, background updates, and cleanup for you.
Think of TanStack Query not as a data-fetching library but as an async state manager. It does not care how you fetch, only that your fetcher returns a Promise. fetch, axios, GraphQL, or a database call all work.
The Basics: useQuery
Everything starts with a QueryClient at the root of your app and a QueryClientProvider that makes it available through context. A single client holds the entire cache.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
retry: 2,
},
},
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
<Dashboard />
</QueryClientProvider>
);
}A query needs two things: a unique queryKey that identifies the data in the cache, and a queryFn that returns a Promise. In return you get the data plus every state flag you would otherwise track by hand.
import { useQuery } from '@tanstack/react-query';
interface Todo {
id: number;
title: string;
completed: boolean;
}
async function fetchTodos(): Promise<Todo[]> {
const res = await fetch('/api/todos');
if (!res.ok) throw new Error('Failed to load todos');
return res.json();
}
function TodoList() {
const { data, isPending, isError, error, isFetching } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
if (isPending) return <p>Loading...</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<ul>
{isFetching && <li>Refreshing...</li>}
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}Note the difference between isPending and isFetching. isPending is true only when there is no cached data yet (the very first load). isFetching is true whenever a request is in flight, including background refetches when data already exists. This distinction lets you show a full skeleton on first load but a subtle indicator on refreshes.
How Do Query Keys and Key Factories Work?
The query key is the identity of your data. TanStack Query hashes it deterministically, so ["todos"] and ["todos"] are the same entry, while ["todos", { status: "done" }] is a distinct one. Keys should be arrays that describe the data from most generic to most specific, and any variable your queryFn depends on must be part of the key.
function useTodo(todoId: number) {
return useQuery({
queryKey: ['todos', todoId],
queryFn: () => fetchTodo(todoId),
});
}
function useTodos(filters: { status?: string }) {
return useQuery({
// filters is part of the key: changing it refetches automatically
queryKey: ['todos', 'list', filters],
queryFn: () => fetchTodos(filters),
});
}As an app grows, ad-hoc keys become a source of bugs. The recommended pattern is a query key factory: a single object that owns every key shape for a feature. This keeps keys consistent and makes invalidation surgical.
export const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: { status?: string }) =>
[...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
};
// Invalidate every todo query: todoKeys.all
// Invalidate all list queries only: todoKeys.lists()
// Invalidate one detail: todoKeys.detail(42)Because invalidation matches keys by prefix, invalidating todoKeys.all catches lists and details alike, while todoKeys.detail(42) targets exactly one entry. The factory encodes that hierarchy in one place.
staleTime vs gcTime: How Does the Cache Lifecycle Work?
The two options people misunderstand most are staleTime and gcTime. They control different phases of a cache entry's life and answer different questions.
The two timers:
- staleTime: how long fetched data is considered fresh. While fresh, TanStack Query serves it from cache and never refetches. Default is 0, meaning data is stale immediately.
- gcTime (garbage collection time, formerly cacheTime): how long an inactive query stays in the cache after its last observer unmounts. Default is 5 minutes.
Walk through the lifecycle. A component mounts and subscribes to a query, making it active. Data is fetched and marked fresh. Once staleTime elapses, the data becomes stale but is still shown instantly from cache. When the component unmounts, the query becomes inactive and the gcTime countdown begins. If nothing re-subscribes before gcTime expires, the entry is removed entirely and the next mount triggers a fresh fetch.
staleTime governs when to refetch. gcTime governs when to forget. Fresh data is never refetched; stale data is refetched in the background on the next trigger while the cached value is shown instantly.
A practical rule: set staleTime based on how often the underlying data actually changes. Reference data that barely moves can use a staleTime of minutes or hours; a live feed should stay at 0. Leaving staleTime at 0 everywhere is a common cause of surprising extra requests.
Background Refetching
When data is stale, TanStack Query refetches it automatically on certain triggers to keep the UI current without blocking the user. Crucially, it shows the cached (stale) data immediately and swaps in fresh data when the request resolves, a stale-while-revalidate strategy.
Default automatic refetch triggers (only when data is stale):
- refetchOnMount: a component using the query mounts
- refetchOnWindowFocus: the browser tab regains focus
- refetchOnReconnect: the network connection is restored
- refetchInterval: a polling interval you configure explicitly
useQuery({
queryKey: ['dashboard', 'metrics'],
queryFn: fetchMetrics,
refetchInterval: 30_000, // poll every 30s
refetchOnWindowFocus: true, // refresh when user returns
staleTime: 10_000, // but only if older than 10s
});How Do Mutations and Cache Invalidation Work?
Queries read data; mutations write it. useMutation wraps a create, update, or delete operation and exposes a mutate function plus the same status flags. The important part is what happens after a successful mutation: your cached queries are now out of date and need to be reconciled with the server.
The simplest and most robust approach is invalidation. After a mutation succeeds, call invalidateQueries with the affected key. This marks matching queries as stale and refetches any that are currently active, so the UI reflects the server's truth without you manually editing the cache.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { todoKeys } from './todoKeys';
function useAddTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (title: string) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ title }),
}).then((r) => r.json()),
onSuccess: () => {
// Refetch every todo list so the new item appears
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
}
function AddTodo() {
const { mutate, isPending } = useAddTodo();
return (
<button
disabled={isPending}
onClick={() => mutate('Write blog post')}
>
{isPending ? 'Adding...' : 'Add todo'}
</button>
);
}How Do You Implement Optimistic Updates with Rollback?
Invalidation is correct but not instant: the user waits for the round trip. For actions that almost always succeed, like toggling a todo, optimistic updates make the UI feel immediate. You update the cache before the server responds, then reconcile. TanStack Query gives you the onMutate, onError, and onSettled lifecycle to do this safely.
The optimistic update flow:
- onMutate: cancel in-flight refetches, snapshot the current cache, then optimistically write the expected new value
- onError: something failed, so roll back to the snapshot you captured
- onSettled: whether it succeeded or failed, invalidate to sync with the server
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo: Todo) =>
fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
body: JSON.stringify({ completed: !todo.completed }),
}),
onMutate: async (todo) => {
// 1. Cancel outgoing refetches so they don't overwrite us
await queryClient.cancelQueries({ queryKey: todoKeys.lists() });
// 2. Snapshot the previous value for rollback
const previous = queryClient.getQueryData<Todo[]>(todoKeys.lists());
// 3. Optimistically update the cache
queryClient.setQueryData<Todo[]>(todoKeys.lists(), (old) =>
old?.map((t) =>
t.id === todo.id ? { ...t, completed: !t.completed } : t
)
);
// 4. Return context for onError
return { previous };
},
onError: (_err, _todo, context) => {
// Roll back to the snapshot
if (context?.previous) {
queryClient.setQueryData(todoKeys.lists(), context.previous);
}
},
onSettled: () => {
// Always resync with the server afterwards
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
}The cancelQueries call in step one is easy to forget but essential: without it, a background refetch that started before your optimistic write could resolve afterwards and clobber your update with stale data.
Dependent Queries
Sometimes one query needs the result of another. The enabled option pauses a query until its prerequisites are ready, preventing requests with undefined parameters.
function UserProjects({ email }: { email: string }) {
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: () => fetchUserByEmail(email),
});
const userId = user?.id;
const { data: projects } = useQuery({
queryKey: ['projects', userId],
queryFn: () => fetchProjects(userId!),
enabled: !!userId, // waits until userId exists
});
return <ProjectList projects={projects ?? []} />;
}Paginated and Infinite Queries
For paginated lists, keeping the page number in the query key gives you a fresh query per page. Add placeholderData to keep the previous page visible while the next one loads, avoiding a jarring flash to a loading state.
import { keepPreviousData } from '@tanstack/react-query';
function Todos({ page }: { page: number }) {
const { data, isPlaceholderData } = useQuery({
queryKey: ['todos', 'page', page],
queryFn: () => fetchTodoPage(page),
placeholderData: keepPreviousData,
});
return (
<div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{data?.items.map((t) => <div key={t.id}>{t.title}</div>)}
</div>
);
}For infinite scrolling or load-more UIs, useInfiniteQuery manages an array of pages and computes the next page parameter for you.
import { useInfiniteQuery } from '@tanstack/react-query';
function Feed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
return (
<div>
{data?.pages.map((page) =>
page.items.map((item) => <Post key={item.id} post={item} />)
)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load more' : 'No more'}
</button>
</div>
);
}select and Derived Data
The select option transforms or narrows the data a component subscribes to. It runs after the queryFn and, importantly, a component only re-renders when its selected slice changes. This makes it a cheap way to derive values or subscribe to just one field of a large response.
// Only re-renders when the *count* changes, not on every todo edit
function TodoCount() {
const { data: count } = useQuery({
queryKey: todoKeys.lists(),
queryFn: fetchTodos,
select: (todos) => todos.length,
});
return <span>{count} items</span>;
}Error Handling and Retries
By default, failed queries retry three times with exponential backoff before surfacing an error. You can tune this per query, and you should think carefully about which errors are worth retrying: a 500 might be transient, but a 404 will never succeed.
useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
retry: (failureCount, error) => {
// Don't retry client errors like 404
if (error instanceof HttpError && error.status === 404) return false;
return failureCount < 3;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
});For a clean separation of concerns, pair queries with React error boundaries via throwOnError, so rendering code deals with success states while a boundary handles failures. You can also set global onError handling on the QueryClient's QueryCache to fire toast notifications in one place.
Prefetching and SSR Hydration (Next.js)
Prefetching warms the cache before a component needs data, eliminating loading spinners. On hover over a link, you can prefetch the destination's data so it is ready on navigation.
function TodoLink({ id }: { id: number }) {
const queryClient = useQueryClient();
const prefetch = () =>
queryClient.prefetchQuery({
queryKey: todoKeys.detail(id),
queryFn: () => fetchTodo(id),
staleTime: 10_000,
});
return <a onMouseEnter={prefetch} href={`/todos/${id}`}>Open</a>;
}For server-side rendering with the Next.js App Router, the pattern is: create a QueryClient on the server, prefetch the data, then dehydrate the cache and pass it to a HydrationBoundary. The client picks up the serialized cache with zero extra fetches on load.
// app/todos/page.tsx (Server Component)
import {
QueryClient,
HydrationBoundary,
dehydrate,
} from '@tanstack/react-query';
import { TodoList } from './TodoList';
import { todoKeys } from './todoKeys';
export default async function TodosPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: todoKeys.lists(),
queryFn: fetchTodos,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{/* TodoList is a Client Component using useQuery with the same key */}
<TodoList />
</HydrationBoundary>
);
}Because the client TodoList uses the exact same query key, it reads the hydrated data instantly and only refetches in the background once the data goes stale. Matching keys between server and client is what makes hydration seamless.
Devtools
The devtools are one of the best reasons to adopt the library. They give you an X-ray of the cache: every query key, its current state (fresh, fetching, stale, inactive), its data, and its observers. When something refetches unexpectedly or an optimistic update misbehaves, the devtools show you exactly what is happening.
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
export function App() {
return (
<QueryClientProvider client={queryClient}>
<Dashboard />
{/* Excluded from production builds automatically */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}It Works in React Native Too
TanStack Query is platform agnostic. The same hooks, keys, and cache work identically in React Native, which makes it a natural fit for teams sharing logic across web and mobile. The only differences are that focus and online detection use platform APIs rather than the browser's.
import { AppState, Platform } from 'react-native';
import { focusManager, onlineManager } from '@tanstack/react-query';
import NetInfo from '@react-native-community/netinfo';
// Wire refetchOnReconnect to NetInfo
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((state) => setOnline(!!state.isConnected))
);
// Wire refetchOnWindowFocus to AppState
AppState.addEventListener('change', (status) => {
if (Platform.OS !== 'web') {
focusManager.setFocused(status === 'active');
}
});With those two managers configured, background refetch on reconnect and on app foreground behave just like they do on the web. You can also persist the cache to AsyncStorage for instant offline-first loads.
Key Takeaways
What to remember:
- Server state is not client state; use a dedicated async manager for it instead of useEffect plus useState
- Query keys are identity: model them as arrays from generic to specific and centralize them in a key factory
- staleTime controls when to refetch, gcTime controls when to forget; tune staleTime to how often data actually changes
- Mutations should reconcile the cache afterwards; invalidateQueries is the simplest correct default
- Use optimistic updates with onMutate, onError rollback, and onSettled for instant-feeling actions, and remember cancelQueries
- Reach for enabled for dependent queries, useInfiniteQuery for feeds, and select for cheap derived data
- Prefetch and hydrate for SSR to eliminate spinners, and lean on the devtools to understand cache behavior
- The same code runs in React Native; just wire focusManager and onlineManager to native APIs
TanStack Query removes an entire class of boilerplate and bugs by treating server state as the distinct, cache-driven concern it really is. Start with useQuery and sensible staleTime defaults, add mutations with invalidation, and layer in optimistic updates and prefetching where the user experience demands it. The result is data-fetching code that is smaller, faster, and far easier to reason about.
