Fix: React Query (TanStack Query) Infinite Refetching Loop
Part of: React & Frontend Errors
Quick Answer
How to fix React Query refetching infinitely — why useQuery keeps fetching, how object and array dependencies cause loops, how to stabilize queryKey, and configure refetch behavior correctly.
The Error
A useQuery hook triggers continuous network requests that never stop:
// This refetches on every render — network tab shows hundreds of requests
const { data } = useQuery({
queryKey: ['users', { page, filters }],
queryFn: () => fetchUsers({ page, filters }),
});Or the query refetches every few seconds even when the data hasn’t changed. Or you see this in React DevTools — the component re-renders repeatedly triggered by React Query state updates.
Why This Happens
React Query (now TanStack Query) is built around an Observer pattern: every useQuery call subscribes one observer to a cache entry keyed by the structural hash of queryKey. When the key hash changes, the old observer is detached and a new one is attached against a different cache entry — which means a fresh fetch. So any input that produces a new hash on every render produces a new fetch on every render. The most common offenders are inline objects, arrays built inside the component body, and dates created via new Date().
The second axis is React Query’s “refetch trigger” matrix. Even with a stable key, the library will refetch on window focus, on network reconnect, on component mount, and on a timer — each governed by a separate option (refetchOnWindowFocus, refetchOnReconnect, refetchOnMount, refetchInterval). The default staleTime is 0, which means data is considered stale the instant it lands, so any of those triggers fires immediately. Combine that with a component that mounts and unmounts on every parent re-render and you get continuous refetches that look like a loop but are actually four overlapping mechanisms firing in sequence.
The third pattern is the feedback loop: a useEffect watching data calls invalidateQueries, which causes a refetch, which produces a new data reference, which re-runs the effect. The same loop can be built with onSuccess callbacks that mutate state outside the query, or with select functions that return a new object every call. Once you see the loop, it is usually obvious; the trick is to recognize the architectural pattern early.
Pattern 1 — unstable queryKey causes re-mounting loops:
React Query uses deep equality to compare query keys. But if the key contains a new object or array reference on every render, React Query sees it as a new query, cancels the current one, and starts a new fetch — causing an infinite loop.
// New object created on every render — triggers new query each time
const { data } = useQuery({
queryKey: ['users', { page: 1, sort: 'name' }], // New object reference every render
queryFn: fetchUsers,
});Pattern 2 — side effects in queryFn or onSuccess that trigger re-renders:
If the queryFn or onSuccess callback updates state, and that state change causes a re-render, and the re-render changes the query key, you get a loop.
Pattern 3 — refetchInterval set too aggressively or staleTime set to 0:
By default, React Query considers data stale immediately (staleTime: 0). Combined with refetchOnWindowFocus: true and refetchOnReconnect: true, frequent tab switching or network changes trigger continuous refetches.
Version History That Changes the Failure Mode
The TanStack Query API has changed shape several times. The same code that worked in v3 behaves differently in v5, so check the installed version before applying any fix from older articles:
- react-query v3.0 (December 2020) — Library still named
react-query. IntroducedcacheTime(the duration cached data remains in memory after the last observer detaches) as separate fromstaleTime. Refetch defaults were aggressive:refetchOnWindowFocus: true,staleTime: 0. - react-query v3.17 (May 2021) — Added
keepPreviousData: true, the canonical way to keep showing the previous page of paginated data while the next page loads. This was a major UX win for paginated tables. - TanStack Query v4.0 (July 2022) — Renamed the package from
react-queryto@tanstack/react-queryand introduced framework adapters (Vue, Solid, Svelte). ThedefaultOptionsAPI moved intonew QueryClient({ defaultOptions: { queries: { ... }, mutations: { ... } } })and the singular-string signatures ofuseQuery('key', fn)were removed in favor of the object form. - TanStack Query v4.29 (May 2023) — Added experimental support for suspense via
useSuspenseQuery, which throws a promise instead of returning a loading state. - TanStack Query v5.0 (October 2023) — Major rename:
cacheTimebecamegcTime(garbage collection time),keepPreviousDatabecame theplaceholderData: keepPreviousDatahelper, and theonSuccess/onError/onSettledcallbacks onuseQuerywere removed entirely (still available onuseMutation). TheuseMutationcallback options were preserved butmutate(variables, { onSuccess })per-call callbacks no longer fire after the component unmounts. - TanStack Query v5.17 (January 2024) — Reworked the network mode and added
experimental_prefetchInRender. - TanStack Query v5.50 (mid-2024) — Added
streamedQueryfor streaming response support.
If you migrated from v4 to v5 and started seeing infinite refetches, the most common cause is leftover onSuccess: () => queryClient.invalidateQueries(...) callbacks that you carried over from useQuery. In v5 those callbacks are gone on queries; the equivalent has to live in useMutation or in a useEffect keyed on stable inputs. The second most common cause is forgetting to rename cacheTime to gcTime — cacheTime is silently ignored in v5, so the cache evicts faster than you expect and observers refetch on remount.
Fix 1: Stabilize the queryKey
The queryKey must be stable — it should not create new object or array references on every render:
Broken — new object on every render:
function UserList({ page, filters }) {
// filters is an object — new reference on every render
const { data } = useQuery({
queryKey: ['users', { page, filters }], // ← Unstable
queryFn: () => fetchUsers(page, filters),
});
}Fixed — use primitive values in the key:
function UserList({ page, filters }) {
const { data } = useQuery({
queryKey: ['users', page, filters.status, filters.search], // ← Stable primitives
queryFn: () => fetchUsers(page, filters),
});
}Fixed — memoize complex objects used in the key:
function UserList({ page, filters }) {
// Memoize filters — only changes when values actually change
const stableFilters = useMemo(
() => ({ status: filters.status, search: filters.search }),
[filters.status, filters.search]
);
const { data } = useQuery({
queryKey: ['users', page, stableFilters],
queryFn: () => fetchUsers(page, stableFilters),
});
}Fixed — serialize the key to a stable string:
const filtersKey = JSON.stringify(filters); // Stable string representation
const { data } = useQuery({
queryKey: ['users', page, filtersKey],
queryFn: () => fetchUsers(page, filters),
});Pro Tip: Keep query keys as flat arrays of primitives whenever possible. Avoid passing entire prop objects into the key. If you need to include an object, ensure it is memoized with
useMemoor stabilized outside the component.
Fix 2: Set staleTime to Reduce Unnecessary Refetches
By default, staleTime: 0 means all data is considered stale immediately after fetching. React Query refetches stale data on window focus, component mount, and network reconnect:
// Configure globally in QueryClient
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // Data is fresh for 1 minute
gcTime: 5 * 60 * 1000, // Cache kept for 5 minutes (was cacheTime in v4)
refetchOnWindowFocus: false, // Don't refetch when user switches tabs
refetchOnReconnect: true, // Keep this — useful for reconnects
retry: 1, // Retry once on failure (default 3)
},
},
});Configure per-query:
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 5 * 60 * 1000, // Users data is fresh for 5 minutes
refetchOnWindowFocus: false,
refetchInterval: false, // Disable polling
});
// For real-time data — poll every 30 seconds
const { data: liveData } = useQuery({
queryKey: ['live-metrics'],
queryFn: fetchMetrics,
staleTime: 0,
refetchInterval: 30_000, // Refetch every 30 seconds
refetchIntervalInBackground: false, // Only poll when tab is active
});Fix 3: Fix Infinite Refetch Caused by queryFn Side Effects
If your queryFn triggers state changes that cause re-renders, and those re-renders change the query key, you get a loop:
Broken — queryFn sets state:
const [results, setResults] = useState([]);
const { data } = useQuery({
queryKey: ['search', query],
queryFn: async () => {
const data = await searchApi(query);
setResults(data); // ← Sets state → triggers re-render → new query key?
return data;
},
});Fixed — use data directly from React Query, don’t duplicate into state:
// React Query IS your state — don't put the result into useState too
const { data: results = [] } = useQuery({
queryKey: ['search', query],
queryFn: () => searchApi(query),
});
// Use 'results' directly — no useState neededIf you need to transform data, use select:
const { data: userNames } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
select: (data) => data.map(u => u.name), // Transform without extra state
});Fix 4: Fix Refetch Loop from useEffect + invalidateQueries
A common pattern that creates a loop: useEffect calls invalidateQueries, which triggers a refetch, which updates data, which re-runs the effect:
Broken — creates a loop:
const { data } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
useEffect(() => {
// This runs every time 'data' changes — which happens after every refetch
queryClient.invalidateQueries({ queryKey: ['users'] }); // ← Loop!
}, [data]);Fixed — invalidate based on user actions, not query data:
const { data } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
// Only invalidate after a mutation — not after every fetch
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
// Trigger invalidation from a user action
<button onClick={() => mutation.mutate(newUser)}>Create User</button>If you must use useEffect, add a stable dependency:
const [lastUpdated, setLastUpdated] = useState(null);
useEffect(() => {
if (externalEvent) {
queryClient.invalidateQueries({ queryKey: ['users'] });
}
}, [externalEvent]); // Only runs when externalEvent changes — not on every data changeFix 5: Fix Refetch Loop with useMutation onSuccess
When onSuccess invalidates a query that the same component uses, and the invalidation causes a re-render that re-runs the mutation:
// Safe pattern — invalidate in onSuccess, not in useEffect watching data
const mutation = useMutation({
mutationFn: (newUser) => createUser(newUser),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
// This triggers a refetch of 'users' — but only once, not in a loop
},
onError: (error) => {
console.error('Failed to create user:', error);
},
});Optimistic updates to avoid the refetch entirely:
const mutation = useMutation({
mutationFn: createUser,
onMutate: async (newUser) => {
// Cancel any in-flight refetches
await queryClient.cancelQueries({ queryKey: ['users'] });
// Snapshot the previous value
const previousUsers = queryClient.getQueryData(['users']);
// Optimistically update
queryClient.setQueryData(['users'], (old) => [...old, newUser]);
return { previousUsers };
},
onError: (err, newUser, context) => {
// Rollback on error
queryClient.setQueryData(['users'], context.previousUsers);
},
onSettled: () => {
// Refetch once after mutation settles (success or error)
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});Fix 6: Disable Refetch Behaviors Globally
For apps where refetching on every focus or reconnect is unwanted:
// providers/QueryClientProvider.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity, // Data never goes stale automatically
refetchOnWindowFocus: false, // Don't refetch on tab focus
refetchOnReconnect: false, // Don't refetch on network reconnect
refetchOnMount: false, // Don't refetch when component mounts
retry: false, // Don't retry on error
},
},
});
export function Providers({ children }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}Note:
staleTime: Infinitymeans React Query never automatically considers data stale. Data only refetches when you explicitly callinvalidateQueriesorrefetch. This is appropriate for data that changes rarely and where you control when to refresh.
Fix 7: Debug Refetching with React Query DevTools
Install and use the DevTools to see exactly why a query is refetching:
npm install @tanstack/react-query-devtoolsimport { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}In the DevTools panel:
- Click a query to see its status, last fetch time, and observer count.
- The “Refetch” button manually triggers a refetch for testing.
- Watch the query state change from
fresh→stale→fetchingto understand the cycle.
Log refetch reasons:
const { data } = useQuery({
queryKey: ['users'],
queryFn: async () => {
console.log('Fetching users at:', new Date().toISOString());
return fetchUsers();
},
});Still Not Working?
Check if the component is unmounting and remounting. If the component that calls useQuery unmounts and remounts repeatedly (due to a parent re-render or router animation), the query restarts each time. Lift the query to a parent component or use keepPreviousData: true:
const { data } = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page),
placeholderData: keepPreviousData, // v5 API — keeps old data while new data loads
});Check React StrictMode. In development with <StrictMode>, React intentionally double-invokes effects and renders. This can make refetching appear worse than it is in production. Test without StrictMode to confirm:
// Temporarily remove StrictMode to test
root.render(
// <React.StrictMode>
<App />
// </React.StrictMode>
);Check the select function for new references. A select callback that returns a new object literal on every call will trigger downstream re-renders even when the underlying data has not changed. Wrap it in useCallback, or return a stable structural identity by serializing to a primitive when the value is small enough:
const select = useCallback(
(data) => data.map(u => u.name).join(','),
[]
);
const { data } = useQuery({ queryKey: ['users'], queryFn: fetchUsers, select });Check for duplicate QueryClient instances. If a parent component re-creates new QueryClient() on every render instead of declaring it once at module scope, each render wipes the cache and every observer refetches. Move the QueryClient constructor out of the component body and into a top-level module variable or a useState initializer.
Check whether you have two QueryClientProvider trees. A second provider mounted inside the first (often by mistake when a routing layout wraps children in its own provider) creates isolated caches that cannot share observers, so any navigation between routes refetches everything. Search the codebase for QueryClientProvider and confirm there is exactly one.
For related React data fetching issues, see Fix: React useEffect Infinite Loop, Fix: React useState Not Updating, Fix: React Query Stale Data, and Fix: React useEffect Missing Dependency.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.
Fix: Blurhash Not Working — Placeholder Not Rendering, Encoding Failing, or Colors Wrong
How to fix Blurhash image placeholder issues — encoding with Sharp, decoding in React, canvas rendering, Next.js image placeholders, CSS blur fallback, and performance optimization.
Fix: Docusaurus Not Working — Build Failing, Sidebar Not Showing, or Plugin Errors
How to fix Docusaurus issues — docs and blog configuration, sidebar generation, custom theme components, plugin setup, MDX compatibility, search integration, and deployment.
Fix: Embla Carousel Not Working — Slides Not Scrolling, Autoplay Not Starting, or Thumbnails Not Syncing
How to fix Embla Carousel issues — React setup, slide sizing, autoplay and navigation plugins, loop mode, thumbnail carousels, responsive breakpoints, and vertical scrolling.