React
4 minutes readReact Query: from setup to advanced techniques, and when you actually need it
Base config, mutations + invalidation, dependent queries, optimistic updates, server-side prefetching with hydration — and a clear rule for React Query vs Redux Toolkit / Zustand / Jotai.

React Query (TanStack Query) solves a different problem from Redux Toolkit, Zustand or Jotai. Those manage client state — data your app fully owns: a modal’s open flag, a form draft, a theme toggle. React Query manages server state — data that lives somewhere else, that you only borrow a copy of, that can quietly go stale and that needs caching, deduplication and background refetching. Reaching for the wrong tool for either job is the source of most of the pain.
Base configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // data is "fresh" for 1 minute — no refetch on remount in this 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 on purpose
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()),
});
}Mutation + invalidation
A mutation changes data; the query cache doesn’t know about that change until you tell it. Invalidating the related keys after a successful mutation is what keeps the UI in sync — without a hand-written 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 for another query’s data before it is 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, // does not 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 for 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 prefetch + hydration (App Router)
Prefetch on the server so the first client-side 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 (project-view.tsx) — 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 previous page until the next one loads
});React Query vs Redux Toolkit / Zustand / Jotai
These aren’t competitors. Most real apps end up using React Query for everything that comes over the network, and a small slice of Zustand or Jotai for leftover local client state — and never need Redux unless the client state itself is large and truly cross-cutting.