React Data Fetching with TanStack Query: The Patterns That Keep Apps Calm

Three months into the insurance CRM, we had a page that felt haunted.
The policy list would load. Then it would blink. Then it would refetch after switching tabs. Then a status change would update one row but leave another count stale. Sometimes the detail page showed fresh data. Sometimes it showed yesterday's version until someone manually refreshed the browser.
Nobody had written obviously bad code.
There was a useEffect here, a loading boolean there, a bit of local state for filters, a manual refetch after save, and a few defensive checks around undefined data. Each decision made sense in isolation. Together, they formed the kind of frontend state machine nobody wants to debug.
I've seen this pattern more times than I can count. Teams think their data-fetching problem is "how do we call the API?" That is the easy part. The real problem is ownership: who decides whether data is fresh, where it is cached, what invalidates it, what happens during pagination, and what the user sees while the network is moving.
That is why TanStack Query matters.
Not because it fetches.
Because it gives server state a home.
The hardest part of data fetching is not the request. It is the lifecycle around the request.
This is article #5 in the React Playbook series. We already covered the starting stack, TypeScript patterns, code structure, and error handling. Now we get to the layer that makes most React apps feel either calm or twitchy: TanStack Query.
Server State Is Not Client State
The first mental shift is simple but important.
Server state is not yours.
It lives somewhere else. It can change without your app knowing. It can be stale. It can fail. It can be shared by multiple users. It has a network boundary, a cache lifetime, and a synchronization problem.
Client state is different. A dialog is open or closed. A sidebar is collapsed. A draft field has local input. That state belongs to the browser session.
The mistake is mixing the two.
const [policies, setPolicies] = useState<Policy[]>([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<Error | null>(null); useEffect(() => { setIsLoading(true); fetchPolicies() .then(setPolicies) .catch(setError) .finally(() => setIsLoading(false)); }, []);
This looks harmless. It is fine for a demo. In production, it immediately raises questions:
- What happens when another component needs the same policies?
- How long is this data fresh?
- Who retries?
- Who cancels the request if the component unmounts?
- What invalidates this list after a mutation?
- What happens when filters change?
- What happens when the user leaves and comes back?
You can answer all of those manually. You can also spend the next year rediscovering why caching is hard.
TanStack Query is the place where those answers live.
Start with Query Options, Not Custom Hooks Everywhere
My default pattern in larger apps is to define query options at the entity boundary.
// src/entities/policy/api/policy.queries.ts import { queryOptions } from '@tanstack/react-query'; import { fetchPolicies, fetchPolicyById } from './policy.api'; import type { PolicyListFilters } from '../model/policy.types'; export const policyKeys = { all: ['policies'] as const, lists: () => [...policyKeys.all, 'list'] as const, list: (filters: PolicyListFilters) => [...policyKeys.lists(), filters] as const, details: () => [...policyKeys.all, 'detail'] as const, detail: (policyId: string) => [...policyKeys.details(), policyId] as const, }; export function policiesQueryOptions(filters: PolicyListFilters) { return queryOptions({ queryKey: policyKeys.list(filters), queryFn: () => fetchPolicies(filters), staleTime: 1000 * 60 * 2, }); } export function policyDetailQueryOptions(policyId: string) { return queryOptions({ queryKey: policyKeys.detail(policyId), queryFn: () => fetchPolicyById(policyId), staleTime: 1000 * 60 * 5, }); }
Then components use the options:
// src/pages/policy-list/PolicyListPage.tsx import { useQuery } from '@tanstack/react-query'; import { policiesQueryOptions } from '@/entities/policy'; export function PolicyListPage() { const filters = usePolicyListFilters(); const policiesQuery = useQuery(policiesQueryOptions(filters)); if (policiesQuery.isPending) return <PolicyListSkeleton />; if (policiesQuery.isError) return <PolicyListError onRetry={() => policiesQuery.refetch()} />; return <PolicyTable policies={policiesQuery.data.items} />; }
Why not just create usePoliciesQuery(filters)?
Sometimes I do. But query options compose better. The same object can be used by useQuery, route loaders, prefetching, tests, and invalidation helpers. It keeps the query definition in one place without forcing every usage through a hook.
The hook becomes optional sugar, not the source of truth.
Query Keys Are Architecture
Query keys look like small arrays. They are not small.
They are the identity of your server state.
If your keys are inconsistent, invalidation becomes guesswork:
useQuery({ queryKey: ['policy', policyId], queryFn: ... }); useQuery({ queryKey: ['policies', 'detail', id], queryFn: ... }); queryClient.invalidateQueries({ queryKey: ['policy'] });
This is how stale UI survives after a successful mutation.
Use factories:
export const policyKeys = { all: ['policies'] as const, lists: () => [...policyKeys.all, 'list'] as const, list: (filters: PolicyListFilters) => [...policyKeys.lists(), filters] as const, details: () => [...policyKeys.all, 'detail'] as const, detail: (policyId: string) => [...policyKeys.details(), policyId] as const, };
This gives you hierarchy:
queryClient.invalidateQueries({ queryKey: policyKeys.lists() }); queryClient.invalidateQueries({ queryKey: policyKeys.detail(policyId) }); queryClient.invalidateQueries({ queryKey: policyKeys.all });
Invalidate all policies. Or only lists. Or one detail. The key shape makes the choice explicit.
The rule is straightforward:
If a variable changes what the query fetches, that variable belongs in the query key.
Filters, page number, cursor, search term, sort order, preview mode, tenant id. Put them in the key. TanStack Query cannot cache what you do not identify.
Freshness Is a Product Decision
TanStack Query's defaults are intentionally active: cached data is stale by default, stale queries refetch on mount, window focus, and reconnect, and inactive queries are garbage-collected after a default cache window.
Those defaults are good for learning and safe for many apps. They are not a substitute for product thinking.
In a CRM, different data has different freshness needs.
export function policyDetailQueryOptions(policyId: string) { return queryOptions({ queryKey: policyKeys.detail(policyId), queryFn: () => fetchPolicyById(policyId), staleTime: 1000 * 60 * 5, }); } export function policyActivityQueryOptions(policyId: string) { return queryOptions({ queryKey: [...policyKeys.detail(policyId), 'activity'], queryFn: () => fetchPolicyActivity(policyId), staleTime: 1000 * 30, }); } export function coverageTypesQueryOptions() { return queryOptions({ queryKey: ['coverage-types'], queryFn: fetchCoverageTypes, staleTime: 1000 * 60 * 60, gcTime: 1000 * 60 * 60, }); }
Policy detail can be fresh for five minutes. Activity should refresh more aggressively. Coverage types barely change during a session.
This is the kind of decision I want visible in code. If every query inherits one global staleTime, you are pretending all data has the same volatility.
It doesn't.
Background Fetching Without UI Jank
One of TanStack Query's best features is that it separates initial loading from background fetching.
export function PolicyListPage() { const filters = usePolicyListFilters(); const policiesQuery = useQuery(policiesQueryOptions(filters)); if (policiesQuery.isPending) return <PolicyListSkeleton />; if (policiesQuery.isError) return <PolicyListError onRetry={() => policiesQuery.refetch()} />; return ( <> {policiesQuery.isFetching && <RefreshingIndicator />} <PolicyTable policies={policiesQuery.data.items} /> </> ); }
isPending means there is no data yet.
isFetching means a request is currently happening.
Those are not the same UX. If you show a full-page skeleton every time isFetching is true, your app will blink constantly. Keep the old data visible. Show a small refreshing indicator if the user needs to know.
This is one of the places where React apps start feeling calm.
Pagination Without Flicker
Pagination is where naive data fetching becomes obvious.
const policiesQuery = useQuery({ queryKey: ['policies', page], queryFn: () => fetchPolicies({ page }), });
Technically correct. Bad experience.
Every page is a different query key, so moving from page 1 to page 2 can bounce the UI back into a pending state. For tables, that feels like the screen is being rebuilt under the user's hands.
Use placeholderData: keepPreviousData:
import { keepPreviousData, useQuery } from '@tanstack/react-query'; export function PolicyListPage() { const [page, setPage] = useState(1); const filters = usePolicyListFilters(); const policiesQuery = useQuery({ ...policiesQueryOptions({ ...filters, page }), placeholderData: keepPreviousData, }); if (policiesQuery.isPending) return <PolicyListSkeleton />; if (policiesQuery.isError) return <PolicyListError onRetry={() => policiesQuery.refetch()} />; return ( <> <PolicyTable policies={policiesQuery.data.items} faded={policiesQuery.isPlaceholderData} /> <Pagination page={page} hasNextPage={policiesQuery.data.hasNextPage} disabled={policiesQuery.isPlaceholderData} onPageChange={setPage} /> </> ); }
Now the previous page stays visible while the next page loads. When the new data arrives, it swaps in. The UI doesn't fall through loading states just because the query key changed.
Small detail. Huge difference.
Dependent Queries
Some data needs another piece of data first.
The policy detail page loads the policy. Then it may load documents for the policy's customer account. You can't fetch those documents until you know the account id.
export function PolicyDetailPage() { const { policyId } = Route.useParams(); const policyQuery = useQuery(policyDetailQueryOptions(policyId)); const documentsQuery = useQuery({ ...customerDocumentsQueryOptions(policyQuery.data?.customerAccountId ?? ''), enabled: Boolean(policyQuery.data?.customerAccountId), }); // ... }
The enabled flag is the important part. It says: this query exists, but it should not run until its input exists.
Don't put conditional hooks around this:
if (policyQuery.data) { const documentsQuery = useQuery(...); }
React hooks don't work that way. Keep the hook unconditional. Control execution with enabled.
Prefetch at the Route Boundary
TanStack Router and TanStack Query fit together cleanly because route loaders can ensure query data before the page renders.
// src/pages/policy-detail/policy-detail.route.ts export const policyDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/policies/$policyId', loader: ({ context, params }) => context.queryClient.ensureQueryData(policyDetailQueryOptions(params.policyId)), component: PolicyDetailPage, });
Then inside the page:
export function PolicyDetailPage() { const { policyId } = Route.useParams(); const policyQuery = useQuery(policyDetailQueryOptions(policyId)); return <PolicyDetailView policy={policyQuery.data} />; }
The page still uses useQuery. That is good. The loader warms the cache; the component subscribes to it. You get route-level loading behavior without inventing a second data path.
For list-to-detail navigation, prefetch on intent:
function PolicyRow({ policy }: { policy: Policy }) { const queryClient = useQueryClient(); return ( <Link to="/policies/$policyId" params={{ policyId: policy.id }} onMouseEnter={() => { queryClient.prefetchQuery(policyDetailQueryOptions(policy.id)); }} > {policy.policyNumber} </Link> ); }
Hover is not the only signal. Focus works for keyboard users. Intersection works for visible rows. The point is the same: fetch before the click when intent is clear enough.
Mutations Should Invalidate Narrowly
Mutations deserve their own full article later, but invalidation belongs here.
After editing a policy status:
export function useEditPolicyStatus() { const queryClient = useQueryClient(); return useMutation({ mutationFn: updatePolicyStatus, onSuccess: (updatedPolicy) => { queryClient.setQueryData( policyKeys.detail(updatedPolicy.id), updatedPolicy, ); queryClient.invalidateQueries({ queryKey: policyKeys.lists() }); }, }); }
Two things happen:
- The detail cache gets the exact updated policy immediately.
- Policy lists are invalidated because that status might affect filters, counts, and visible rows.
I prefer this over invalidating everything:
queryClient.invalidateQueries();
That is the data-fetching equivalent of turning the lights off and on again. Sometimes useful during development. Rarely the right production behavior.
Be specific. Your users can feel the difference.
Don't Mirror Query Data Into Local State
This is the smell I look for in reviews:
const policiesQuery = useQuery(policiesQueryOptions(filters)); const [policies, setPolicies] = useState<Policy[]>([]); useEffect(() => { if (policiesQuery.data) { setPolicies(policiesQuery.data.items); } }, [policiesQuery.data]);
Now there are two sources of truth. Query cache and local state. They will drift.
Usually the fix is simple:
const policies = policiesQuery.data?.items ?? [];
If you need derived data, derive it:
const activePolicies = useMemo( () => policiesQuery.data?.items.filter((policy) => policy.status === 'active') ?? [], [policiesQuery.data?.items], );
If you need editable draft state, make that explicit:
const policyQuery = useQuery(policyDetailQueryOptions(policyId)); const form = useForm({ defaultValues: policyQuery.data, });
Server data can seed a draft. Once it becomes a draft, it is client state. Name the transition. Don't hide it in a useEffect.
The Review Checklist
When I review TanStack Query code, I ask:
- Does every query key include every variable used by the query function?
- Are keys created through a factory instead of hand-written arrays everywhere?
- Does each query have a deliberate
staleTime? - Is pagination using
placeholderDatawhen flicker would hurt? - Are dependent queries controlled with
enabled? - Are route loaders using the same query options as components?
- Do mutations invalidate the narrowest useful key?
- Is query data being mirrored into local state unnecessarily?
- Does the UI distinguish first load from background refetch?
That checklist catches most of the data bugs I see in React applications.
What Comes Next
TanStack Query is the server-state layer. Once that layer is stable, routing becomes much easier to reason about: routes can preload the same query options, search params can drive query keys, and navigation can become type-safe instead of stringly typed.
Article #6: routing with TanStack Router — route trees, typed params, search validation, loaders, navigation, and how Router and Query should meet without turning every page into glue code.
The haunted policy list eventually became boring. Not because we added more spinners. Because the data model became explicit. Query keys had hierarchy. Freshness was intentional. Mutations invalidated the right caches. Pagination stopped flickering. Route loaders warmed the detail page before the user landed there.
That is what I want from data fetching in React.
Not cleverness. Calm.
If your React app has started to feel like the UI is arguing with the network, reach out. The fix is often not another loading state. It is giving server state one clear owner.