A useEffect that fetches data, sets state, and then re-runs because of that state change is the classic infinite loop. The cure is understanding what actually changes between renders.
The Core Rule
React re-runs an effect whenever any value in its dependency array changes by reference. Primitives (string, number, boolean) compare by value; objects, arrays, and functions are recreated on every render, so they are *always* 'new'.
Trap 1: An Object or Array in Dependencies
// ❌ options is a new object every render → effect runs every render
const options = { page, size: 20 };
useEffect(() => { fetchUsers(options); }, [options]);
// ✅ Depend on the primitives
useEffect(() => { fetchUsers({ page, size: 20 }); }, [page]);Trap 2: A Function in Dependencies
If an effect depends on a function defined in the component, wrap that function in useCallback so its identity is stable:
const load = useCallback(async () => {
const res = await fetch(`/api/users?page=${page}`);
setUsers(await res.json());
}, [page]);
useEffect(() => { load(); }, [load]);Trap 3: Setting State the Effect Depends On
// ❌ effect depends on count and also changes it
useEffect(() => { setCount(count + 1); }, [count]);
// ✅ Use the functional updater and the right trigger
useEffect(() => { setCount((c) => c + 1); }, [someTrigger]);Don't 'Fix' It by Removing Dependencies
Deleting items from the dependency array to stop the loop hides stale-closure bugs. Fix the *identity* of the dependency with useCallback/useMemo instead.
Once you internalize 'objects and functions are new every render', useEffect loops become obvious — and the React DevTools 'why did this render' feature will point at the exact unstable dependency.
