Skip to content

Fix: React Query (TanStack Query) Infinite Refetching Loop

FixDevs ·

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 refetches in two distinct patterns:

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.

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>
);

For related React data fetching issues, see Fix: React useEffect Infinite Loop and Fix: React useState Not Updating.

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