The React useEffect hook connects your components to the world outside React — network requests, subscriptions, timers, browser APIs. It is powerful, but used carelessly it produces infinite loops, memory leaks, and "ghost" behavior that is hard to debug. This guide covers the dependency array, cleanup logic, and the most common mistakes with real examples.
What useEffect is for
An effect is a side effect that runs after render. React paints the screen first, then runs your effect. That makes effects right for work that talks to the outside world and does not directly shape the render output:
- Fetching data from a server (
fetch, axios) - Setting up a WebSocket or event subscription
- Starting a
setInterval/setTimeout - Updating the document title or browser APIs
If you are only computing one value from another, you do not need an effect — do it during render. An effect is needed when you touch something outside the render.
The dependency array: the heart of it
useEffect behaves in three ways, and the difference is the second argument, the dependency array:
// 1) Runs after every render
useEffect(() => {
console.log('every render');
});
// 2) Runs only on the first mount
useEffect(() => {
console.log('once');
}, []);
// 3) Runs whenever userId changes
useEffect(() => {
fetchUser(userId);
}, [userId]);
The rule is simple: every reactive value (prop, state, function) you use inside the effect must appear in the dependency array. Omitting a dependency makes the effect run with stale values. The React team's eslint-plugin-react-hooks ships an exhaustive-deps rule that catches this for you; fix the root cause instead of silencing the warning.
The cleanup function and memory leaks
If an effect sets up something persistent (a subscription, a timer, a listener), you are also responsible for tearing it down. The function you return from the effect runs before the component is removed from the DOM and before the effect runs again next time:
useEffect(() => {
const id = setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
return () => clearInterval(id); // cleanup
}, []);
Without cleanup, every re-run stacks up a new setInterval that keeps firing even after the component unmounts — a classic memory leak. The same rule applies to event listeners:
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
Race conditions
A common trap when fetching: if the user clicks through records quickly, an older request can resolve late and overwrite the newer data. Prevent it with a flag (or an AbortController) in cleanup:
useEffect(() => {
let active = true;
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then((data) => {
if (active) setUser(data);
});
return () => {
active = false; // the stale request no longer updates state
};
}, [userId]);
In modern browsers, truly aborting the request with AbortController is cleaner. In larger apps, libraries like TanStack Query or SWR handle these race conditions and caching for you.
The most common mistakes
- Omitting a dependency: values you leave out of the array get frozen (stale closure). Fix: follow the
exhaustive-depsrule. - Infinite loop with an empty array: the real problem is usually the opposite — putting an object/array/function that is recreated on every render into the array re-triggers the effect endlessly. Stabilize those with
useMemo/useCallback. - Putting derived data in an effect: don't use an effect to compute one piece of state from another; compute it during render.
- Forgetting cleanup: write a teardown for every subscription/timer.
- Passing an async function directly:
useEffect(async () => …)is wrong; the effect must return a cleanup function, not a Promise. Define and call a separate async function inside.
Why does StrictMode run the effect twice?
In development, React 18+ under StrictMode deliberately mounts each effect once, immediately unmounts it, then mounts it again. The goal is to surface whether your cleanup is written correctly. Don't panic about a "bug" when your effect fires twice; if you write cleanup properly the behavior is harmless and does not happen in a production build.
Frequently Asked Questions
What is the difference between useEffect and useLayoutEffect?
useEffect runs asynchronously after the browser finishes painting; it is the right choice in most cases. useLayoutEffect runs synchronously after the DOM updates but before paint; use it only when you must measure and adjust layout immediately (for example, positioning a tooltip), otherwise it can hurt performance.
What happens if I never pass a dependency array?
If you omit the second argument entirely, the effect runs after every render. That is rarely what you want and usually means you meant to write [] or the real dependencies.
Is an effect the best way to fetch data?
For small cases, yes, but libraries like TanStack Query or SWR are a sturdier choice for most projects because they handle caching, retries, and race conditions automatically.
Have effects gotten out of control in your React architecture? Let's review your components and clean up stale closures and leaks. Get in touch and we'll harden your project together.