React Query: Config to Advanced Techniques, and When to Actually Use It

React Query (TanStack Query) solves a different problem than Redux Toolkit, Zustand, or Jotai. Those manage client state — data your app owns entirely: a modal's open/closed flag, a form draft, a theme toggle. React Query manages server state — data that lives somewhere else, that you're only borrowing a copy of, that can go stale behind your back and needs caching, deduping, and background refetching. Reaching for the wrong tool for either job is where most of the pain comes from.
Basic config
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // data is "fresh" for 1 minute — no refetch on remount within that window
gcTime: 5 * 60 * 1000, // unused cache entries are garbage-collected after 5 minutes
refetchOnWindowFocus: false, // noisy default for most admin/dashboard UIs — turn it off deliberately
retry: 1,
},
},
});
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}The basic shape
function useProject(slug: string) {
return useQuery({
queryKey: ["project", slug],
queryFn: () => fetch("/api/projects/" + slug).then((r) => r.json()),
});
}Mutations + invalidation
A mutation changes data; the query cache doesn't know about that change until it's told. Invalidating the relevant query keys after a successful mutation is what keeps the UI in sync without a manual refetch call scattered everywhere.
function useUpdateProject(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: ProjectInput) =>
fetch("/api/projects/" + slug, { method: "PATCH", body: JSON.stringify(data) }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["project", slug] });
queryClient.invalidateQueries({ queryKey: ["projects"] }); // the list view too
},
});
}Advanced — dependent queries
A query can wait on data from another query before it's allowed to run:
const { data: user } = useQuery({ queryKey: ["user"], queryFn: getUser });
const { data: orders } = useQuery({
queryKey: ["orders", user?.id],
queryFn: () => getOrders(user!.id),
enabled: !!user?.id, // won't run until user.id exists
});Advanced — optimistic updates
Update the cache immediately, before the server confirms, and roll back if the mutation fails — the UI feels instant instead of waiting on a round trip.
useMutation({
mutationFn: updateProject,
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ["project", slug] });
const previous = queryClient.getQueryData(["project", slug]);
queryClient.setQueryData(["project", slug], newData);
return { previous };
},
onError: (_err, _newData, context) => {
queryClient.setQueryData(["project", slug], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["project", slug] });
},
});Advanced — server-side prefetch + hydration (App Router)
Prefetch on the server so the client's first useQuery call reads an already-warm cache instead of firing a fresh request and showing a loading state.
// Server Component
export default async function ProjectPage({ params }: { params: { slug: string } }) {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["project", params.slug],
queryFn: () => getProject(params.slug),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProjectView slug={params.slug} />
</HydrationBoundary>
);
}
// Client Component — reads the already-warm cache, no loading flash.
("use client");
function ProjectView({ slug }: { slug: string }) {
const { data } = useQuery({ queryKey: ["project", slug], queryFn: () => getProject(slug) });
return <h1>{data.title}</h1>;
}Advanced — pagination without a loading flash
const { data } = useQuery({
queryKey: ["projects", page],
queryFn: () => getProjects(page),
placeholderData: keepPreviousData, // keep showing the old page while the new one loads
});When to use React Query vs. Redux Toolkit / Zustand / Jotai
Question | Answer |
|---|---|
Did this data come from a fetch call and can it go stale behind your back? | React Query — that's exactly its job: caching, deduping, background refetch, retry. |
Is it purely local UI state you fully own (modal open, form draft, active tab)? | Zustand / Jotai — no network, no cache invalidation policy needed for that. |
Do you need heavy cross-cutting client state shared across a large app, with strict, traceable updates? | Redux Toolkit — still the right call for that specific shape of problem. |
Are you using React Query and Redux for the same server data at once? | Drop the Redux slice — it's now fighting React Query's cache instead of complementing it. |
They aren't competitors. Most real apps end up using React Query for everything that came from the network, and a small slice of Zustand or Jotai for the local UI state left over — and never need Redux at all unless the client-side state itself is large and genuinely cross-cutting.
More posts

Authentication with better-auth in Next.js: A First-Hand Setup
The exact setup behind this site's own admin login: server config, the Next.js catch-all route handler, the client, and the two-layer middleware + layout protection that actually secures /admin.

Recharts in Next.js + Tailwind: Theming and Custom Styling
Making Recharts feel native to a Tailwind design system: correct Client Component boundaries, wiring colors to CSS variables for automatic dark mode, custom tooltips/legends, and handling RTL locales.