đ Executive Summary
TL;DR: Infinite loops in Reactâs useEffect often stem from referential equality issues with non-primitive dependencies defined within components. Solutions range from hoisting static values and memoizing dynamic ones with useMemo/useCallback to abstracting data fetching entirely using custom hooks or libraries like TanStack Query.
đŻ Key Takeaways
- JavaScriptâs referential equality for non-primitive types (objects, arrays, functions) is the root cause of many useEffect infinite loops.
- Defining objects or functions inside a component and including them in useEffectâs dependency array will cause the effect to re-run on every render due to new references being created.
- Solutions include hoisting static dependencies outside the component, using useMemo for objects/arrays and useCallback for functions to stabilize references, or abstracting data fetching logic with custom hooks or libraries like TanStack Query for a more robust, declarative approach.
Struggling with infinite loops and re-renders in React? Weâll break down a common useEffect anti-pattern I see constantly in code reviews and show you how to fix it for good, from a quick patch to a permanent architectural solution.
From My Code Review Desk: The useEffect Anti-Pattern Thatâs Crashing Your App
I still remember the PagerDuty alert that woke me up at 3:17 AM on a Tuesday. The alert was for our primary user-auth service, and the metrics were terrifying: CPU on our prod-auth-api-01 node was pinned at 100%, and network traffic was through the roof. It looked like a classic DDoS attack. After 20 minutes of frantic digging, we found the culprit. It wasnât an external attacker. It was us. A junior engineer had pushed a seemingly harmless UI change, which included a useEffect hook that was caught in an infinite loop, relentlessly hammering our own /api/v1/users/me endpoint thousands of times per second from every active userâs browser. Thatâs the day I started taking useEffect code reviews very seriously.
The âWhyâ: Understanding Referential Equality
Before we jump into the fixes, letâs get to the root of the problem. Itâs not really useEffectâs fault; itâs about how JavaScript handles objects and functions. In JavaScript, non-primitive types (Objects, Arrays, Functions) are compared by reference, not by value. This means {} === {} is always false, because they are two separate objects in memory, even if they look identical.
Reactâs useEffect hook runs its effect after every render *if* something in its dependency array has changed. When you put an object or function thatâs defined inside your component body into that dependency array, youâre setting a trap. On every single render, a new function or object is created. React compares the ânewâ object to the âoldâ one, sees they are different references, and dutifully re-runs your effect. If that effect updates state, it triggers another render, which creates another new object, and⌠you get the picture. Hello, 3 AM wake-up call.
The Code That Triggers the PagerDuty Alert
Hereâs a simplified version of the code that caused our outage. We have a component that needs to fetch user data based on a set of options.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// This object is re-created on EVERY render
const fetchOptions = {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
};
useEffect(() => {
console.log('Fetching user data...');
fetch(`/api/users/${userId}`, fetchOptions)
.then(res => res.json())
.then(data => setUser(data));
// The linter correctly warns us that `fetchOptions` is a dependency.
// But adding it causes an infinite loop!
}, [userId, fetchOptions]); // <-- The trap is set
if (!user) return <p>Loading...</p>;
return <h2>Welcome, {user.name}</h2>;
}
The developer saw the linting rule âReact Hook useEffect has a missing dependency: âfetchOptionsââ and dutifully added it to the array. Boom. Infinite loop. The fetch causes a state update, which causes a re-render, which creates a new fetchOptions object, which causes the effect to run again.
Solution 1: The âQuick & Dirtyâ Fix (Hoisting)
Sometimes you just need to stop the bleeding. The fastest way to fix this specific issue is to move the dependency outside of the component, so itâs only created once when the module is loaded, not on every render.
The Code:
// Define it ONCE, outside the component's render cycle.
const FETCH_OPTIONS = {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
};
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`, FETCH_OPTIONS)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]); // Now `fetchOptions` is stable and not needed here
// ... render logic
}
Why It Works (and When It Doesnât):
This works because FETCH_OPTIONS is now a constant with a stable reference. Itâs never re-created. This is a great fix if the object is truly static and doesnât depend on any props or state. If your object needs to include a dynamic value (like a token from state), this approach falls apart.
Solution 2: The âProper Engineerâ Fix (useMemo & useCallback)
The ârightâ way to solve this within the component is to tell React, âHey, donât re-create this thing unless its own dependencies have changed.â We use the useMemo hook for objects/arrays and useCallback for functions.
The Code:
import { useState, useEffect, useMemo, useCallback } from 'react';
function UserProfile({ userId, authToken }) {
const [user, setUser] = useState(null);
// useMemo ensures this object reference is stable
// unless `authToken` itself changes.
const fetchOptions = useMemo(() => ({
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
}), [authToken]);
// If you were defining a fetch function instead, you'd use useCallback
const fetchUser = useCallback(() => {
return fetch(`/api/users/${userId}`, fetchOptions)
.then(res => res.json());
}, [userId, fetchOptions]);
useEffect(() => {
fetchUser().then(data => setUser(data));
}, [fetchUser]); // The dependency is now a stable, memoized function
// ... render logic
}
Why It Works:
useMemo and useCallback âmemoizeâ the value. They give you back the exact same object or function reference on every re-render, *unless* one of the dependencies you pass to *them* (e.g., [authToken]) has changed. This breaks the infinite loop while still allowing the effect to re-run when things it truly depends on (like the auth token) actually change.
A Word of Caution: Donât go wrapping everything in
useMemo. It has a small performance cost. Use it when you are passing non-primitive values to dependency arrays or as props to memoized child components. Donât use it for simple calculations.
Solution 3: The âNuclear Optionâ (Abstract the Logic)
After youâve fixed this bug a few times, you start to realize the problem is architectural. Data fetching logic is a cross-cutting concern; it doesnât really belong inside a UI component. The best long-term solution is to move it out completely.
The Fix: A Custom Hook or a Library
You can create your own custom hook to encapsulate the fetching logic, state, and effects. Or, even better for production apps, use a battle-tested library like TanStack Query (formerly React Query) or SWR.
The Code (with TanStack Query):
import { useQuery } from '@tanstack/react-query';
const fetchUser = async (userId) => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) {
throw new Error('Network response was not ok');
}
return res.json();
};
function UserProfile({ userId }) {
// All the useEffect, useState, error handling, and loading logic
// is now handled by this one hook.
const { data: user, isLoading, isError } = useQuery({
queryKey: ['user', userId], // A unique key for this data
queryFn: () => fetchUser(userId),
});
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Error loading user.</p>;
return <h2>Welcome, {user.name}</h2>;
}
Why Itâs the Best Option:
This approach completely sidesteps the problem. The library authors have already solved all the tricky dependency and re-fetching issues for you. You just tell it *what* to fetch and *when* to consider it stale. It handles caching, background refetching, and eliminates the need for manual useEffect management for server state. This makes your components cleaner, more declarative, and much less prone to bugs like the one that cost me a nightâs sleep.
So next time youâre in a code review and see a complex useEffect, take a moment to think about the dependency array. You might just save your team from a 3 AM fire drill.
đ Read the original article on TechResolve.blog
â Support my work
If this article helped you, you can buy me a coffee:

Top comments (0)