Skip to content

Fix: React Query (TanStack Query) Infinite Refetching Loop

FixDevs · (Updated: )

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. Introduced cacheTime (the duration cached data remains in memory after the last observer detaches) as separate from staleTime. 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-query to @tanstack/react-query and introduced framework adapters (Vue, Solid, Svelte). The defaultOptions API moved into new QueryClient({ defaultOptions: { queries: { ... }, mutations: { ... } } }) and the singular-string signatures of useQuery('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: cacheTime became gcTime (garbage collection time), keepPreviousData became the placeholderData: keepPreviousData helper, and the onSuccess/onError/onSettled callbacks on useQuery were removed entirely (still available on useMutation). The useMutation callback options were preserved but mutate(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 streamedQuery for 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 gcTimecacheTime 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 useMemo or 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 needed

If 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 change

Fix 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: Infinity means React Query never automatically considers data stale. Data only refetches when you explicitly call invalidateQueries or refetch. 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-devtools
import { 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 freshstalefetching to 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles