Client-side data fetching in React has a clear standard bearer: TanStack Query. Even in a world where Server Components handle initial reads in some architectures, enormous numbers of React apps are still client-rendered dashboards, admin panels, and interactive tools where a query cache is the backbone of the data layer. The library is easy to start with and easy to use badly. This post covers the patterns that separate maintainable TanStack Query codebases from ones drowning in refetch bugs: query key design, cache tuning, mutation flows, and error handling in layers.
The mental model: a normalized-ish cache keyed by arrays
TanStack Query is not a fetching library; your fetcher is whatever returns a promise. It is an asynchronous state manager: it caches results by key, tracks staleness, deduplicates concurrent requests, refetches in the background, and garbage-collects unused entries. Everything hangs off the query key, an array that should read like a path from general to specific:

useQuery({
queryKey: ['todos', 'list', { status: 'open', page: 2 }],
queryFn: () => fetchTodos({ status: 'open', page: 2 }),
}); Hierarchical keys enable hierarchical invalidation: invalidating ['todos'] refetches every todo query, while ['todos', 'detail', id] targets one record. Two rules keep keys reliable. First, every variable the query function uses belongs in the key; a filter used in the fetcher but missing from the key is the number one source of stale-data bugs. Second, centralize keys in a small factory object so components never hand-assemble arrays:
export const todoKeys = {
all: ['todos'],
lists: () => [...todoKeys.all, 'list'],
list: (filters) => [...todoKeys.lists(), filters],
detail: (id) => [...todoKeys.all, 'detail', id],
}; staleTime is your caching strategy
The most consequential setting in the library is staleTime: how long data is considered fresh. Fresh data is served from cache with no network request; stale data is served instantly from cache and refetched in the background. The default is zero, meaning everything is immediately stale and refetches on every mount and window focus. That default is safe but chatty, and tuning it is where apps get dramatically better:
- Reference data such as countries, categories, and settings:
staleTimeof thirty minutes or more. - List and dashboard data: thirty seconds to a few minutes, so navigation feels instant while edits still surface quickly.
- Fast-moving data such as live statuses: zero, or a
refetchIntervalfor polling.
Set a sensible global default in the QueryClient and override per query. Distinguish staleTime from gcTime, which controls how long inactive data stays in memory before garbage collection; the first governs network behavior, the second memory.
Wrap queries in custom hooks per domain, useTodoList(filters), useTodo(id), so options live in one place. The select option is a quiet performance tool: it derives a slice from the cached result, and the component re-renders only when the selected slice changes.
Mutations: invalidate first, optimize later
Mutations change server state, and the question that matters is what happens to your cache afterward. The robust default is invalidation: mark affected queries stale and let the library refetch the truth.

const queryClient = useQueryClient();
const addTodo = useMutation({
mutationFn: (newTodo) => api.post('/todos', newTodo),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
}); Invalidation keeps client and server convergent with almost no code, and hierarchical keys make it precise. Reach for optimistic updates only where latency genuinely hurts the experience, such as toggles and likes, and follow the full recipe: cancel outgoing refetches, snapshot the previous cache, apply the optimistic value, roll back in onError, and always invalidate in onSettled so the server remains the source of truth. The snapshot-and-rollback in context is what most hand-rolled versions forget:
const toggleTodo = useMutation({
mutationFn: (id) => api.patch(`/todos/${id}/toggle`),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: todoKeys.lists() });
const previous = queryClient.getQueryData(todoKeys.list({}));
queryClient.setQueryData(todoKeys.list({}), (old) =>
old.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
return { previous };
},
onError: (err, id, ctx) => {
queryClient.setQueryData(todoKeys.list({}), ctx.previous);
},
onSettled: () =>
queryClient.invalidateQueries({ queryKey: todoKeys.lists() }),
}); Error handling in three layers
Production apps handle query errors at three distinct levels, and conflating them produces either alert spam or silent failures. Layer one is retry policy: the library retries failed queries three times with exponential backoff by default. Make retries smarter by bailing on client errors, since a 404 will not succeed on attempt three: retry: (count, error) => error.status >= 500 && count < 3.
Layer two is rendering: for data a screen cannot exist without, prefer throwing to an error boundary with throwOnError: true plus a QueryErrorResetBoundary, giving users a single retry surface instead of per-widget error states. For peripheral widgets, handle isError inline with a compact fallback. Layer three is notification: background refetch failures on data you are already showing should not blank the screen; a global QueryCache onError callback that shows a toast only when cached data exists covers this elegantly.
Loading states, Suspense, and prefetching
Distinguish isPending, no data yet, from isFetching, background refresh in progress. Skeletons belong on the first, a subtle indicator at most on the second; showing spinners during background refetches makes a fast app feel slow. If you prefer declarative loading, useSuspenseQuery integrates with Suspense boundaries and guarantees data is defined below them. For perceived speed, prefetch on intent: hovering a link is a great moment for queryClient.prefetchQuery, and route loaders can prefetch before the component tree renders. Paginated screens should set placeholderData to keep the previous page visible while the next loads, which eliminates pagination flicker entirely.

Closing advice
Treat TanStack Query as your server-state layer, not a cache bolted onto components: keys in factories, queries wrapped in domain hooks, staleTime chosen deliberately, mutations that invalidate by default, and errors handled at the right layer. Do that, and data fetching in React stops being the source of your weirdest bugs and becomes the most predictable part of the app.
Related Service
๐ป Web Development
Custom websites and web applications built with PHP, Laravel, WordPress, and React by a Chandigarh & Mohali based web developer โ fast, secure, scalable, and trusted by clients across India and worldwide.
Explore Web Development →
Reviews & Comments
Reviews are moderated and appear after approval.
No reviews yet โ be the first to share your thoughts.
Leave a Review