useEffect and Cleanup Patterns
useEffect Is Not componentDidMount
Let's kill the most damaging mental model right now: thinking of useEffect(() => {}, []) as componentDidMount. It's not. useEffect is a synchronization mechanism. It synchronizes your component with an external system. The dependency array tells React when to re-synchronize.
// Mental model: "run this on mount" — WRONG
useEffect(() => {
fetchUser(userId);
}, []);
// If userId changes, this effect does not re-run.
// The component is out of sync.
// Mental model: "synchronize with userId" — CORRECT
useEffect(() => {
fetchUser(userId);
}, [userId]);
// When userId changes, effect re-runs. Always in sync.
Think of useEffect as a live connection to an external system. The setup function creates the connection. The cleanup function tears it down. The dependency array specifies what the connection depends on. When a dependency changes, React tears down the old connection and creates a new one. This is not "run once on mount" — it is "keep this component synchronized."
The Effect Lifecycle
Every effect goes through this cycle:
1. Component renders with current props/state
2. React commits to DOM and browser paints
3. React runs the effect's setup function
4. (Later) Component re-renders with new props/state
5. React runs the PREVIOUS effect's cleanup function
6. React runs the NEW effect's setup function
7. (On unmount) React runs the final cleanup function
function ChatRoom({ roomId }) {
useEffect(() => {
console.log(`Setup: connect to ${roomId}`);
const connection = createConnection(roomId);
connection.connect();
return () => {
console.log(`Cleanup: disconnect from ${roomId}`);
connection.disconnect();
};
}, [roomId]);
}
// Switching from roomId='general' to roomId='react':
// 1. "Setup: connect to general" (mount)
// 2. "Cleanup: disconnect from general" (before new effect)
// 3. "Setup: connect to react" (new effect)
Cleanup runs BEFORE the new effect, not after unmount
A common misconception: cleanup only runs on unmount. In reality, cleanup runs before EVERY re-execution of the effect. When roomId changes from 'general' to 'react', the old cleanup disconnects from 'general' BEFORE the new setup connects to 'react'. This prevents resource leaks — you never have two connections open simultaneously. The final cleanup on unmount is just the last one.
Dependency Array Deep Dive
This is where most useEffect bugs are born. Every value from the component scope that your effect uses must be in the deps array:
function SearchResults({ query, sortBy }) {
const [results, setResults] = useState([]);
useEffect(() => {
// This effect reads query AND sortBy
fetchResults(query, sortBy).then(setResults);
}, [query, sortBy]); // Both must be listed
}
How React compares dependencies:
React compares each dependency with its value from the previous render using Object.is:
useEffect(() => {
// Runs when ANY dep changes by Object.is comparison
}, [a, b, c]);
// Object.is('hello', 'hello') → true → no re-run
// Object.is(42, 42) → true → no re-run
// Object.is({}, {}) → false → RE-RUN (new reference!)
The Object/Array Dependency Trap
function Search({ query }) {
// NEW object on every render!
const options = { query, limit: 10 };
useEffect(() => {
fetchResults(options);
}, [options]); // Object.is fails every render → infinite loop
}
Fixes:
// Fix 1: Depend on primitives, construct object inside effect
useEffect(() => {
fetchResults({ query, limit: 10 });
}, [query]);
// Fix 2: useMemo the object
const options = useMemo(() => ({ query, limit: 10 }), [query]);
useEffect(() => {
fetchResults(options);
}, [options]);
The Infinite Loop Gallery
Let's look at every way you can accidentally create an infinite loop. You'll recognize at least one of these.
Loop 1: Missing Deps Array
useEffect(() => {
setCount(count + 1); // Triggers re-render → effect runs → setState → re-render → ...
}); // No deps array = runs after EVERY render
Loop 2: Object in Deps
useEffect(() => {
fetchData(config);
}, [{ endpoint: '/api', limit: 10 }]); // New object literal every render
Loop 3: setState Without Condition
useEffect(() => {
const data = transform(rawData);
setTransformed(data); // Triggers re-render → effect runs → setState → ...
}, [rawData]); // If rawData is a new reference each render → infinite
// Fix: compute during render, not in effect
const transformed = useMemo(() => transform(rawData), [rawData]);
Loop 4: Function in Deps
function Component({ onFetch }) {
useEffect(() => {
onFetch(); // If onFetch is recreated each render → infinite
}, [onFetch]); // Parent must memoize onFetch with useCallback
}
The most insidious infinite loop: setting state in an effect that depends on a prop that is a new object/function reference each render. The parent re-renders, passes a new reference, the effect re-runs, sets state, the parent re-renders again. The fix is almost always at the parent level — memoize the prop — not in the child.
Race Condition Prevention
Now for the pattern you'll use in basically every production app. The standard approach for cancelling stale async operations:
useEffect(() => {
let ignore = false;
async function fetchData() {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (!ignore) {
setUser(data);
}
}
fetchData();
return () => {
ignore = true;
};
}, [userId]);
With AbortController for true cancellation:
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = await response.json();
setUser(data);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
}
}
}
fetchData();
return () => controller.abort();
}, [userId]);
Production Scenario: Debounced Search with Cleanup
function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
const data = await res.json();
setResults(data);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Search failed:', err);
}
}
}, 300);
return () => {
clearTimeout(timeoutId);
controller.abort();
};
}, [query]);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultsList results={results} />
</div>
);
}
This cleanup prevents: (1) debounced fetch for stale query, (2) in-flight request for old query overwriting new results.
| What developers do | What they should do |
|---|---|
| Thinking useEffect with [] is componentDidMount The mental model matters: componentDidMount implies 'run once'. Sync mental model leads to correct dependency arrays. | Think of useEffect as synchronization — [] means 'sync with nothing that changes' |
| Suppressing exhaustive-deps lint warnings Missing dependencies cause stale closures. The linter catches real bugs. Suppressing it hides them. | Fix the root cause — restructure the effect, move variables inside, or use functional updaters |
| Using objects/arrays as dependencies without memoization New object references on every render cause the effect to re-run every render, potentially creating infinite loops. | Depend on primitives, or memoize with useMemo |
| Not cleaning up subscriptions, timers, or async operations Without cleanup, old subscriptions stack up, timers fire after unmount, and stale async results overwrite fresh data. | Return a cleanup function that cancels everything the setup created |
- 1useEffect is synchronization, not lifecycle — think 'keep in sync with', not 'run on mount'
- 2Cleanup runs before next effect AND on unmount — tear down old before building new
- 3Every value from component scope used in the effect must be in the dependency array
- 4Depend on primitives, not objects/arrays — or memoize them to prevent infinite loops
- 5Always clean up async operations with ignore flags or AbortController
Challenge: Fix the Race Condition
Challenge: Async Effect Race Condition
// This component has a race condition. Fast network responses
// for old queries can overwrite slow responses for new queries.
// Fix it using the cleanup pattern.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(\`/api/users/\${userId}\`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <p>Loading...</p>;
return <div>{user.name}</div>;
}
Show Answer
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
async function loadUser() {
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = await res.json();
setUser(data);
setLoading(false);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Failed to load user:', err);
setLoading(false);
}
// AbortError is expected on cleanup — do nothing
}
}
loadUser();
return () => controller.abort();
}, [userId]);
if (loading) return <p>Loading...</p>;
return <div>{user.name}</div>;
}When userId changes, the cleanup aborts the in-flight request for the old userId. The AbortError is caught and ignored. Only the response for the current userId updates state.