Back to blog

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

July 24, 20264 min read
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.