React Query vs Redux for Server State
When a React application exhibits UI flickers during route transitions, duplicate GET requests for the same endpoint, or optimistic updates that revert unexpectedly, the root cause is usually a mismatch between how the tool manages client vs server state boundaries. This page is a decision and migration guide for teams who need to pick the right tool — or combine both responsibly — when handling async server data in production. For the broader question of when a query cache is the right fit at all, see when to use global state vs query cache. Both pages sit within State Architecture & Cache Fundamentals.
When the symptom points to the wrong tool
Before choosing between the libraries, confirm you are solving a server-state problem and not a general state problem.
- UI flickers between a loading spinner and stale data on every navigation, even after the data has been fetched once
- Multiple sibling components independently fire
GETrequests for the same URL at mount time - An optimistic update succeeds on the network but the UI rolls back a moment later
- Redux DevTools shows large normalized slices being manually reset on every route change
gcTimeandstaleTimedo not appear anywhere in the codebase — the app manages expiry entirely in thunk middleware
If three or more of these apply, server state is being stored in Redux and the deduplication, background revalidation, and garbage collection that React Query provides out of the box are being reinvented by hand.
Architecture: what each tool owns
The key constraint is that the two stores must not share server payloads. Mirroring an API response into both a React Query cache entry and a Redux slice is the primary source of stale-read bugs and race conditions, because they update at different times and through different mechanisms.
Step 1 — Audit Redux slices for server state leakage
Before migrating, identify which Redux slices hold async server data rather than ephemeral UI state.
Signals that a slice holds server state:
- The slice has a
loading,error, ordatafield populated by acreateAsyncThunk - The slice resets on route change or component unmount
- The slice contains raw API response objects or normalized entity maps derived from a network call
- Open Redux DevTools and capture a state snapshot mid-session.
- For each top-level slice key, ask: “Would this data be wrong if the user had two browser tabs open?” If yes, it is server state that should live in the query cache.
- List the endpoints those slices call — each becomes a
queryKeyin step 2.
Step 2 — Replace createAsyncThunk with useQuery
The most direct migration replaces a Redux thunk + reducer pair with a single useQuery declaration. The cache key, deduplication, background revalidation, and garbage collection are all automatic.
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
// Types
interface Item {
id: string;
name: string;
updatedAt: string;
}
async function fetchItems(filter: string): Promise<Item[]> {
const res = await fetch(`/api/items?filter=${encodeURIComponent(filter)}`);
if (!res.ok) throw new Error('Failed to fetch items');
return res.json();
}
async function updateItem(payload: { id: string; name: string }): Promise<Item> {
const res = await fetch(`/api/items/${payload.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: payload.name }),
});
if (!res.ok) throw new Error('Update failed');
return res.json();
}
// Component
function ItemList({ filter }: { filter: string }) {
const queryClient = useQueryClient();
// React Query v5: queryKey includes every variable the fetch depends on
const { data, isLoading, isFetching } = useQuery({
queryKey: ['items', { filter }],
queryFn: () => fetchItems(filter),
staleTime: 30_000, // treat as fresh for 30 s — no background fetch
placeholderData: keepPreviousData, // keep prior page visible during filter change
});
const mutation = useMutation({
mutationFn: updateItem,
onMutate: async (newPayload) => {
// Cancel any in-flight refetch so it does not overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['items', { filter }] });
const previous = queryClient.getQueryData<Item[]>(['items', { filter }]);
// Apply optimistic update to the cache directly
queryClient.setQueryData<Item[]>(['items', { filter }], (old = []) =>
old.map((item) => (item.id === newPayload.id ? { ...item, ...newPayload } : item))
);
return { previous };
},
onError: (_err, _payload, context) => {
// Roll back to the snapshot captured in onMutate
if (context?.previous) {
queryClient.setQueryData(['items', { filter }], context.previous);
}
},
onSettled: () => {
// Always revalidate after a mutation so the cache reflects server truth
queryClient.invalidateQueries({ queryKey: ['items'] });
},
});
if (isLoading) return <p>Loading…</p>;
return (
<ul>
{(data ?? []).map((item) => (
<li key={item.id}>
{item.name}
<button
disabled={mutation.isPending}
onClick={() => mutation.mutate({ id: item.id, name: `${item.name} (edited)` })}
>
Edit
</button>
</li>
))}
{isFetching && <li aria-live="polite">Refreshing…</li>}
</ul>
);
}
Cache Behavior Analysis. When filter changes, React Query creates a new cache entry under the key ['items', { filter }] and fires a background fetch. Because placeholderData: keepPreviousData is set, the component renders the previous filter’s results while the new fetch is in flight — isFetching is true but isLoading is false. The onMutate → onError → onSettled chain gives a snapshot-based optimistic update with guaranteed rollback on network failure, replacing roughly 80 lines of reducer + thunk + selector boilerplate.
Step 3 — Configure staleTime and gcTime per endpoint
The most common post-migration mistake is leaving both values at their defaults (staleTime: 0, gcTime: 5 min) for every query, which causes every component mount to trigger a background refetch regardless of data volatility.
// queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // 1 min default: most dashboard data
gcTime: 10 * 60_000, // 10 min: keep inactive cache entries longer than stale window
retry: 1,
refetchOnWindowFocus: true,
},
},
});
// Per-query overrides — pass in useQuery's options object
// High-churn (live notifications, stock prices): staleTime: 0
// Low-churn (user profile, config): staleTime: 5 * 60_000
// Immutable (reference data, product catalogue): staleTime: Infinity
Cache Behavior Analysis. staleTime controls when a cache entry transitions from “fresh” (no background fetch on mount) to “stale” (background fetch fires on next observer mount or window focus). gcTime controls when an inactive entry (zero observers) is garbage-collected from memory entirely. Setting gcTime shorter than staleTime is a footgun: the entry is evicted before it would ever be used as a fresh hit.
Edge Cases & Gotchas
Gotcha 1 — queryKey instability from object literals in render
Passing an inline object directly into queryKey without memoization causes a new array reference on every render, which React Query treats as a new key and fires a fresh network request.
// BAD: new object reference every render → infinite refetch loop
useQuery({ queryKey: ['items', { filter: props.filter }], queryFn: fetchItems });
// GOOD: stable reference via useMemo or a separate variable
const queryKey = useMemo(() => ['items', { filter: props.filter }] as const, [props.filter]);
useQuery({ queryKey, queryFn: () => fetchItems(props.filter) });
React Query v5 serialises queryKey arrays with a deterministic hash, so { filter: 'a' } and { filter: 'a' } will match even as different object references — but only if the values are primitives. If the key contains a function or a class instance, the hash breaks and you get a new key every render.
Gotcha 2 — Hydration mismatch with initialData from SSR
Passing server-fetched data as initialData without also setting initialDataUpdatedAt tells React Query the data is immediately stale (age = 0), triggering a background refetch the moment the client hydrates.
// SSR-fetched payload passed down as prop
useQuery({
queryKey: ['items', { filter }],
queryFn: () => fetchItems(filter),
initialData: ssrData,
initialDataUpdatedAt: Date.now(), // marks the data as fresh at SSR render time
staleTime: 30_000,
});
For Next.js App Router, prefer HydrationBoundary with prefetchQuery to seed the query cache on the server rather than threading initialData through props.
Gotcha 3 — Redux and React Query writing the same normalized entity
In a hybrid migration, a Redux slice and a React Query cache entry can both hold a copy of the same entity. A mutation that invalidates the query cache does not update the Redux slice, so Redux-connected components display stale data until the next dispatch.
Resolution: during migration, identify every component that renders entity data, then move them to useQuery before retiring the corresponding Redux slice. Do not leave both connected simultaneously.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| UI flickers between spinner and stale data on every navigation | staleTime: 0 (default) causes an immediate background refetch on every component mount even when cached data is fresh |
Set staleTime to match the endpoint’s expected update frequency; use placeholderData: keepPreviousData for paginated or filtered views |
Duplicate GET requests fire in parallel from sibling components |
Two independent useQuery calls with the same key mounted simultaneously before the first response lands |
React Query deduplicates by key automatically; verify both calls use an identical serialised queryKey — any key difference bypasses deduplication |
Optimistic update reverts despite a 200 OK response |
onSettled calls invalidateQueries which fetches stale server data that lags behind the UI; the onError rollback fires because the refetch resolved a 304 with an old body |
Ensure the server returns the updated entity in the mutation response and use queryClient.setQueryData in onSuccess to apply it directly instead of relying solely on invalidation |
Frequently Asked Questions
Can React Query and Redux coexist in the same application?
Yes, with strict ownership contracts. React Query owns all async server state — the query cache is the single source of truth for data that originates from a network call. Redux owns synchronous UI state: modal visibility, multi-step form drafts, local filter values that are never persisted to the server. Avoid duplicating API response objects in both stores simultaneously; when a mutation completes, invalidateQueries updates the query cache and Redux-connected components should not need to know about it.
How do I prevent cache invalidation storms when switching from Redux to React Query?
Use invalidateQueries with a precise predicate or a nested key prefix rather than invalidating the entire cache. Group related queries under a shared first key element (e.g. ['items']) and pass queryKey: ['items'] to invalidation — this narrows the blast radius to that subtree. For mutations that only affect a single entity, prefer queryClient.setQueryData(['items', id], updatedItem) to update the cache in place without triggering additional network requests.
When does server response normalization still belong in Redux rather than React Query?
When multiple unrelated parts of the application need to perform frequent client-side joins across entity types that arrive through separate API calls, and the join logic is complex enough to warrant a centralised selector layer. For most applications — particularly those with straightforward REST endpoints where each query owns its entity — structuralSharing in React Query provides enough deduplication at the value level without requiring a manual normalization schema. If you reach for createEntityAdapter, consider whether designing stable query keys for React Query achieves the same result with less boilerplate.
Related
- Client vs Server State Boundaries — the parent topic; explains why the distinction matters before choosing a tool.
- When to Use Global State vs Query Cache — the sibling decision guide covering architectural triggers for each approach.
- Designing Stable Query Keys for React Query — goes deeper on
queryKeydesign to prevent the unstable-key pitfall described above. - State Architecture & Cache Fundamentals — the top-level reference for cache layer architecture, normalization principles, and reference vs value storage.