Data Fetching with React Query
- react
- reactquery
- tanstack-query
- nextjs
- typescript
- webdev
- frontend
- data-fetching
Once your routes are locked down with Protecting Routes with Middleware, the next problem shows up fast: every protected page needs data, and that data needs to load, refresh, and stay in sync without turning your components into a mess of useEffect calls and manual loading flags.
This is part of the React & Next.js Complete Guide series, where we build up the patterns a real SaaS frontend needs, one problem at a time. Today's problem is data fetching on the client: how to fetch it, cache it, keep it fresh, and not fight React along the way.
I've written the "fetch in a useEffect, store in useState" pattern more times than I'd like to admit, and it works right up until you need two components to share the same data, or you need to refetch after a mutation, or a user tabs away and back and the data is stale. That's the point where reaching for a dedicated data-fetching library stops being a nice-to-have and starts being the obviously correct call.
Why manual fetching breaks down
A useEffect fetch looks simple:
function useUser(userId: string) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) setUser(data);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [userId]);
return { user, loading };
}
This is fine as a one-off. The trouble starts when it multiplies. Drop this hook into three sibling components on the same page and you get three separate network requests for the same user, three separate loading states that flicker independently, and no shared source of truth. Add a mutation somewhere (updating the user's name) and now you need to remember every place that fetched this user and manually tell it to refetch.
None of this is a React problem. It's a caching problem, and caching is a solved problem if you stop solving it yourself. React Query (now published as @tanstack/react-query) exists specifically to own the parts of data fetching that have nothing to do with your UI: request deduplication, caching, background refetching, retries, and stale-data invalidation.
Setting up the query client
The core idea is a QueryClient that holds a cache, and a provider that exposes it to the component tree.
// lib/query-client.ts
import { QueryClient } from "@tanstack/react-query";
export function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: true,
},
},
});
}
// app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { createQueryClient } from "@/lib/query-client";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => createQueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
The useState initializer here is deliberate, not decoration. If you create the QueryClient inline as new QueryClient() in the component body, you get a fresh cache on every render, which quietly defeats the entire point of caching. Wrapping the construction in useState's lazy initializer guarantees it's created exactly once per component instance.
staleTime is the setting worth understanding before anything else. It controls how long data is considered fresh after a fetch. Fresh data is served straight from cache with zero network requests; stale data is still shown immediately but triggers a background refetch. The default staleTime is zero, meaning everything is stale the instant it lands, which surprises people the first time they see a request fire on every remount. Setting a sane default (I use 30 seconds for most SaaS dashboards) cuts a large chunk of redundant traffic without meaningfully hurting freshness.
Writing the query itself
With the provider in place, fetching becomes a hook call, not a lifecycle dance.
// hooks/use-user.ts
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
import type { User } from "@/types/user";
async function fetchUser(userId: string): Promise<User> {
const { data } = await apiClient.get<User>(`/users/${userId}`);
return data;
}
export function useUser(userId: string) {
return useQuery({
queryKey: ["users", userId],
queryFn: () => fetchUser(userId),
enabled: Boolean(userId),
});
}
function UserProfile({ userId }: { userId: string }) {
const { data: user, isPending, isError, error } = useUser(userId);
if (isPending) return <ProfileSkeleton />;
if (isError) return <ErrorState message={error.message} />;
return <ProfileCard user={user} />;
}
The queryKey is doing more work than it looks like. React Query uses it both as a cache key and as a dependency array: change userId and the key changes, so a new fetch happens and the old entry stays cached under its own key. Drop three UserProfile components with the same userId anywhere in the tree and they share one cached entry and one in-flight request; React Query deduplicates automatically. That single behavior removes an entire category of bugs and wasted requests that manual fetching never handles for free.
Structure your keys hierarchically, from general to specific: ["users"], then ["users", userId], then ["users", userId, "posts"]. That structure matters because it lets you invalidate a whole family of related queries with one call after a mutation, which we'll cover properly in the next article.
Handling loading, error, and empty states deliberately
It's tempting to treat isPending as the only state that matters, but a production dashboard needs to distinguish a few cases that look similar but aren't:
isPending: no cached data exists yet and a fetch is in flight. Show a skeleton.isFetchingwhiledatais defined: a background refetch is happening on data you already have. Show the existing data with maybe a subtle indicator, not a full-page skeleton. Replacing good data with a spinner on every window focus is a fast way to make an app feel broken.isError: the query failed after exhausting retries. Show a real error state with a retry action, not a silent blank screen.- Empty success: the request succeeded and returned an empty array or null. This is a UI decision, not a React Query concern, but it's the state people forget to design for.
function PostsList({ userId }: { userId: string }) {
const { data: posts, isPending, isFetching, isError, refetch } = useQuery({
queryKey: ["users", userId, "posts"],
queryFn: () => fetchUserPosts(userId),
});
if (isPending) return <ListSkeleton rows={5} />;
if (isError) return <ErrorState onRetry={refetch} />;
if (posts.length === 0) return <EmptyState message="No posts yet" />;
return (
<div aria-busy={isFetching}>
{posts.map((post) => (
<PostRow key={post.id} post={post} />
))}
</div>
);
}
Server components, App Router, and where React Query fits
If you're on Next.js's App Router, a fair question is whether React Query is even needed, given that server components can fetch data directly and Next's fetch has its own caching layer. The honest answer: it depends on what's being fetched and by whom.
Server components are the right tool for data needed to render the initial page, especially data that doesn't change per user interaction. React Query earns its place for client-side interactivity: data that refetches on an interval, data tied to client-only state (a selected filter, a paginated table page), or data you want to keep warm in cache across route changes so navigating back to a page doesn't refetch everything from scratch.
A common, defensible pattern is to fetch the initial data on the server and hand it to a client component as the initial value for a query, using initialData or, more reliably, prefetching into the query cache and hydrating it:
// app/dashboard/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import { DashboardClient } from "./dashboard-client";
export default async function DashboardPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["dashboard", "summary"],
queryFn: fetchDashboardSummary,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DashboardClient />
</HydrationBoundary>
);
}
This gives you server-rendered first paint with no loading spinner, and hands off to React Query's client cache for everything after that: refetch on focus, background updates, and cache sharing with sibling components. It's more moving parts than either approach alone, so I'd only reach for it once a page's data genuinely needs both a fast first paint and client-side freshness. For a lot of internal dashboard pages, plain server-side fetching without any hydration dance is simpler and entirely sufficient.
Production pitfalls worth naming
A few mistakes show up repeatedly in codebases that adopted React Query without reading past the quickstart.
Query keys built from unstable references (a new object literal on every render) will bust the cache constantly and look like React Query "isn't caching," when the actual bug is that the key itself never matches. Keep keys made of primitives: strings, numbers, and stable IDs.
Setting staleTime: Infinity everywhere to "stop the extra requests" removes the entire benefit of the library. You end up with data that never refreshes and a manual invalidation problem you built the library specifically to avoid. Tune staleTime per query based on how often the underlying data actually changes; a list of countries can be Infinity, a live order queue should be seconds, not minutes.
Forgetting enabled on dependent queries causes fetches with undefined IDs to fire before the data they depend on has loaded. Guard dependent queries explicitly rather than relying on the queryFn to fail gracefully.
Finally, mixing React Query with a separate global state manager for the same server data (Redux slices duplicating what's already in the query cache) creates two sources of truth that drift apart. Let React Query own server state entirely; keep client-only state like form inputs and UI toggles in useState or a lightweight state manager, and don't blur the two.
Key takeaways
- Manual `useEffect` fetching doesn't scale past one component; it duplicates requests, loading state, and refetch logic across a codebase.
- React Query centralizes caching, deduplication, retries, and background refetching behind a small hook API (`useQuery`).
- `staleTime` is the setting that determines perceived freshness versus network traffic; tune it per query, don't default it to zero or infinity everywhere.
- Structure query keys hierarchically (`["users", userId, "posts"]`) so related queries can be invalidated together later.
- Distinguish `isPending` (no data yet) from `isFetching` (refetching data you already have) in your UI; conflating them causes jarring full-page reloads on every background refresh.
- In the App Router, prefetch on the server and hydrate into the client query cache only when a page genuinely needs both fast first paint and client-side freshness; plain server fetching is often enough.
Frequently asked questions
Is React Query still relevant with Next.js App Router and server components?
Yes, for client-side interactivity: data that refetches on focus or interval, data tied to client state like filters and pagination, or data you want cached across route navigations. Server components handle the initial render; React Query handles what happens after the page is interactive.
What's the difference between staleTime and gcTime (formerly cacheTime)?
`staleTime` controls how long data is considered fresh, meaning no background refetch is triggered on remount or focus. `gcTime` controls how long unused query data stays in memory before being garbage collected. A query can be stale but still cached, or fresh but about to be garbage collected if nothing is using it.
Should I use React Query alongside Redux or Zustand?
Use React Query for server state (anything that lives on your backend) and a lightweight state manager only for genuinely client-only state, like form drafts or UI toggles. Duplicating server data into Redux alongside the query cache creates two sources of truth that drift out of sync.
Why does my query refetch every time the component remounts?
The default `staleTime` is zero, so every mount treats existing data as stale and triggers a refetch, even though it still renders the cached data instantly. Set a `staleTime` appropriate to how often the data actually changes to reduce this.
How do I stop a query from firing before its dependencies are ready?
Use the `enabled` option and pass a boolean expression that's false until the required arguments (a user ID, a selected filter) exist. This prevents wasted requests with `undefined` or empty parameters.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.