Difficulty: Intermediate
Explain useEffect in depth. How do you handle cleanup, dependencies, and race conditions?
useEffect is probably the hook that causes the most confusion, the most bugs, and the most heated debates in the React community. But once you really internalize the mental model, it becomes one of your most powerful tools. So let's go deep.
The core idea of useEffect is synchronization. React's official documentation frames it as "synchronizing your component with an external system." Your component renders based on props and state - that's the pure, declarative part. But the real world isn't pure. You need to fetch data from APIs, set up WebSocket connections, register event listeners, write to localStorage, interact with browser APIs, update document.title. These are all side effects - operations that reach outside React's world. useEffect is how you bridge that gap.
The timing is important. useEffect runs asynchronously after the component renders and after the browser has painted the updated DOM. This means the user sees the updated UI first, and then the effect fires. This is intentional - React doesn't want your side effects blocking the visual update. If you need something to happen synchronously before paint (like measuring a DOM element so you can position a tooltip), that's what useLayoutEffect is for.
The dependency array is the key to controlling when effects run, and it's where most confusion lives. There are three patterns:
1. No dependency array: useEffect(() => { ... }). The effect runs after every single render. This is rarely what you want, and it's a common mistake. If your effect does any state updates, you'll create an infinite render loop.
2. Empty dependency array: useEffect(() => { ... }, []). The effect runs once, after the initial render, and never again. The cleanup runs when the component unmounts. This is your "run on mount" pattern - great for one-time setup like registering a global event listener or initializing a third-party library.
3. Specific dependencies: useEffect(() => { ... }, [userId, query]). The effect runs after the initial render and then again whenever any of the listed dependencies change. React compares each dependency with its previous value using Object.is. If any are different, the effect re-runs. This is the most common pattern.
Cleanup is half of the useEffect story, and skipping it is the number one cause of memory leaks in React applications. The function you return from useEffect is the cleanup function. It runs in two situations: before the effect re-runs (when dependencies change), and when the component unmounts. This pairing is actually one of useEffect's brilliant design choices. In class components, you had to manually coordinate componentDidMount (set up) with componentWillUnmount (tear down) and componentDidUpdate (tear down old, set up new). With useEffect, setup and cleanup are inherently paired. Every effect run gets its own cleanup that fires before the next run.
So what needs cleanup? Event listeners (window.addEventListener needs window.removeEventListener), timers (setInterval needs clearInterval, setTimeout needs clearTimeout), subscriptions (WebSocket.onmessage needs WebSocket.close), and fetch requests (abort them to avoid state updates on unmounted components).
Race conditions are the other big topic, and this one is critical for production applications. Here's the scenario: you have a component that fetches user data based on a userId prop. The user navigates quickly, changing userId from 1 to 2 to 3 in rapid succession. Three fetch requests go out. But network requests don't resolve in order - maybe the request for user 2 takes 3 seconds while user 3 returns in 200ms. Without protection, the response for user 2 arrives last and overwrites the correct data for user 3. Your UI shows the wrong user.
The simplest fix is the boolean flag pattern. Inside your effect, you declare let cancelled = false. In the cleanup function, you set cancelled = true. When the fetch resolves, you check if (!cancelled) before calling setState. This way, when userId changes from 2 to 3, the cleanup from the userId=2 effect sets cancelled=true, and when that old request finally resolves, it's ignored.
The more robust fix is AbortController. You create a new AbortController, pass its signal to the fetch call, and call controller.abort() in the cleanup function. This actually cancels the network request instead of just ignoring the result. The fetch promise rejects with an AbortError, which you catch and ignore. This is better because you're not wasting bandwidth on requests whose results you'll throw away.
Dependency pitfalls are another area where developers get burned. Objects and arrays create new references on every render. If you define const options = { limit: 10 } inside your component body and put it in the dependency array, the effect re-runs on every render because it's a new object every time, even though the values are identical. The fixes: move the object inside the useEffect (if it doesn't depend on props/state), memoize it with useMemo, or depend on the primitive values individually instead of the whole object.
Functions have the same problem. If you define a function in your component body and reference it in useEffect's dependency array, it's a new function every render. The fix is useCallback to memoize the function, or better yet, move the function inside the useEffect if it's only used there.
The exhaustive-deps ESLint rule is your best friend here. It warns you when you're missing dependencies, which would cause stale closure bugs. Don't disable this rule - if it's complaining, it's usually right, and the fix is to either add the missing dependency, move the dependency inside the effect, or restructure your code.
useLayoutEffect deserves a mention because it's useEffect's synchronous sibling. It fires after DOM mutations but before the browser paints. Use it when you need to measure DOM elements and adjust layout before the user sees anything - positioning tooltips, measuring text for truncation, synchronous animations. But use it sparingly, because it blocks painting. For the vast majority of effects - data fetching, event listeners, logging - useEffect is what you want.
One last piece of practical advice: in modern React, raw useEffect for data fetching is increasingly seen as a code smell. Libraries like React Query (TanStack Query) handle data fetching with built-in solutions for caching, race conditions, loading states, error states, refetching, pagination, and optimistic updates. They solve all the problems we just discussed and more. If you're writing a production app, use React Query for data fetching and reserve raw useEffect for non-data-fetching side effects like event listeners, timers, and DOM measurements.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// BUG: Race condition
// If userId changes quickly, a slow request for user 1
// can overwrite the result for user 2
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
// FIX 1: Boolean flag
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
});
return () => { cancelled = true; };
}, [userId]);
// FIX 2: AbortController (preferred)
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort();
}, [userId]);
return user ? <h1>{user.name}</h1> : <p>Loading...</p>;
}
Race conditions happen when effects fire faster than async operations complete. AbortController actually cancels the network request, while the boolean flag just ignores the stale response.
function SearchResults({ query }) {
const [results, setResults] = useState([]);
// INFINITE LOOP: options object is recreated every render
const options = { limit: 10, sort: 'date' };
useEffect(() => {
fetchResults(query, options).then(setResults);
}, [query, options]); // options is new every render!
// FIX 1: Move object inside useEffect
useEffect(() => {
const options = { limit: 10, sort: 'date' };
fetchResults(query, options).then(setResults);
}, [query]);
// FIX 2: useMemo for dynamic values
const options = useMemo(
() => ({ limit: 10, sort: sortField }),
[sortField]
);
useEffect(() => {
fetchResults(query, options).then(setResults);
}, [query, options]);
// FIX 3: Depend on primitives, not objects
useEffect(() => {
fetchResults(query, { limit: 10, sort: sortField })
.then(setResults);
}, [query, sortField]); // primitives are stable
}
Objects and arrays create new references on every render. Move them inside useEffect, memoize them, or depend on their primitive components instead.
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
function Tooltip({ targetRef, text }) {
const tooltipRef = useRef(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
// useEffect: runs AFTER paint (async)
// Can cause visible flicker if positioning elements
useEffect(() => {
const rect = targetRef.current.getBoundingClientRect();
setPosition({ top: rect.bottom + 8, left: rect.left });
}, [targetRef]);
// useLayoutEffect: runs BEFORE paint (sync)
// Blocks paint until complete - no flicker
useLayoutEffect(() => {
const rect = targetRef.current.getBoundingClientRect();
setPosition({ top: rect.bottom + 8, left: rect.left });
}, [targetRef]);
return (
<div
ref={tooltipRef}
style={{ position: 'fixed', top: position.top, left: position.left }}
>
{text}
</div>
);
}
// Rule of thumb:
// useEffect -> data fetching, subscriptions, logging
// useLayoutEffect -> DOM measurements, synchronous visual updates
useLayoutEffect fires synchronously after DOM mutations but before the browser paints. Use it for DOM measurements and visual adjustments to prevent flickering.
useEffect, Cleanup, Dependencies, Race Conditions, Data Fetching