React 30 ⚛️ Side Effects Introduction and useEffect
A React component is a function that returns JSX. In the purest sense, it should do nothing else. It should not fetch data, subscribe to events, set timers, or touch the DOM. It should take props and state, and produce a description of the UI. This purity is what makes components predictable and testable. But real applications need to do all of those things. They need to load data from a server, listen for window resizes, update the document title, and synchronize with external systems. These operations are called side effects, and useEffect is the hook that runs them.
This chapter introduces side effects and the useEffect hook. You will learn what a side effect is, why it belongs outside the render phase, the syntax of useEffect, the dependency array that controls when it runs, and the cleanup function that undoes what the effect did. You will also see the common patterns—fetching data, subscribing to events, setting timers—and the pitfalls that produce infinite loops, stale closures, and memory leaks.
Key point: useEffect runs after the render is committed to the DOM. It does not run during render. The dependency array determines when the effect re-runs: after every render, after specific values change, or only once on mount. The cleanup function returned from the effect runs before the next effect execution and on unmount. Every effect that subscribes, subscribes, or creates something should return a cleanup function.
Why side effects need a separate mechanism
The pure render problem. React may call a component function multiple times before committing a render. It may call it during a state update, during a parent re-render, or during a concurrent rendering pass that is later discarded. If the component performs a side effect during render—a fetch, a subscription, a DOM mutation—that side effect may run multiple times, or it may run for a render that never commits. The result is duplicated requests, orphaned subscriptions, and inconsistent state. Side effects must run outside the render phase, once per committed render.
The timing problem. A component’s render needs to be fast. It may be called often, and any work it does delays the update. Fetching data during render would block the render, freezing the UI. Subscribing to an event during render would add listeners before the DOM exists. The side effect must run after the DOM is in place, and it must not block the render. useEffect schedules the effect to run after the browser has painted the committed changes.
The lifecycle problem. In class components, side effects were placed in lifecycle methods: componentDidMount, componentDidUpdate, and componentWillUnmount. Each lifecycle method handled a different part of the effect’s lifecycle—setting up, updating, and tearing down. useEffect unifies these into a single function with a cleanup return. An effect sets up its work, and the cleanup function tears it down. The effect runs on mount and whenever its dependencies change; the cleanup runs before the next run and on unmount.
The dependency problem. An effect that runs after every render is usually wasteful. A data fetch that depends on a user ID should only re-run when the user ID changes, not on every keystroke in an unrelated field. The dependency array tells React which values the effect depends on. React compares the current values to the previous values, and if any have changed, it runs the cleanup and then the effect again.
The cleanup problem. Subscriptions, timers, and event listeners must be removed when the component unmounts, or when the effect re-runs with new dependencies. Without cleanup, the application leaks memory: old listeners fire for components that no longer exist, timers continue after the component is gone, and subscriptions accumulate. useEffect provides a place for the cleanup: the function returned from the effect.
a. Basic syntax and the dependency array
The useEffect hook takes two arguments: a function (the effect) and an optional dependency array.
import { useEffect } from 'react';
useEffect(() => {
// side effect
}, [dependencies]);
The effect function runs after the render is committed. The dependency array controls when it runs.
No dependency array. The effect runs after every render.
useEffect(() => {
console.log('Runs after every render');
});
This is rarely what is wanted. It runs on mount, on every state change, on every prop change, and on every parent re-render. Most effects should have a dependency array.
Empty dependency array. The effect runs once, after the initial render.
useEffect(() => {
console.log('Runs once on mount');
}, []);
This is the pattern for setup that happens once: fetching initial data, subscribing to a global event, setting a document title based on a fixed value. The effect does not re-run on subsequent renders.
Dependency array with values. The effect runs when any of the values change.
useEffect(() => {
console.log(`User ID changed to ${userId}`);
}, [userId]);
The effect runs on mount and whenever userId changes. If userId is the same between renders, the effect does not run. React compares each value in the array to its previous value using Object.is. If any value differs, the effect re-runs.
The dependency array is not optional in the sense that omitting it changes the behavior. Omitting it means “run after every render,” which is a specific choice. Including an empty array means “run once.” Including values means “run when those values change.” The array is how the effect declares its dependencies.
The effect function can be async, but useEffect does not accept an async function directly. An async function returns a promise, and useEffect expects either nothing or a cleanup function. The pattern is to define an async function inside the effect and call it:
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
};
fetchData();
}, [userId]);
The inner async function is called immediately. The effect returns nothing, so no cleanup is registered. If the effect needs cleanup—for example, an AbortController to cancel the fetch on unmount—it returns a function:
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal
});
const data = await response.json();
setUser(data);
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
};
fetchData();
return () => controller.abort();
}, [userId]);
The cleanup function aborts the request when the component unmounts or when userId changes. This prevents the “set state on unmounted component” warning and avoids race conditions where an older request resolves after a newer one.
b. Cleanup functions
The cleanup function is returned from the effect. It runs before the next effect execution and on unmount.
useEffect(() => {
const subscription = subscribeToEvent('event', handler);
return () => {
subscription.unsubscribe();
};
}, []);
The effect subscribes on mount. The cleanup unsubscribes on unmount. If the dependency array were not empty, the cleanup would also run before each re-subscription when the dependencies change.
The pattern applies to every resource that needs releasing:
// Timer
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
// Event listener
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// WebSocket
useEffect(() => {
const ws = new WebSocket('wss://example.com');
ws.onmessage = (e) => setMessage(e.data);
return () => ws.close();
}, []);
// IntersectionObserver
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => setVisible(entry.isIntersecting));
});
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
In each case, the effect creates a resource, and the cleanup releases it. Without the cleanup, the resource leaks.
The cleanup function should not depend on values that change, because it captures the values from the render in which the effect ran. If the cleanup needs to reference a value, that value should be in the dependency array, so the effect and cleanup re-run together when it changes.
c. Common patterns and pitfalls
Fetching data. The most common effect. Fetch on mount or when a dependency changes, set the result in state, and clean up with an abort controller.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true;
};
}, [userId]);
if (!user) return <p>Loading…</p>;
return <p>{user.name}</p>;
}
The cancelled flag prevents setting state after the component unmounts or after userId changes. This is an alternative to AbortController when the fetch library does not support aborting.
Subscribing to events. Subscribe on mount, unsubscribe on unmount. Include the dependencies that the handler uses.
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
The onClose function is a dependency. If it changes between renders, the effect re-subscribes with the new function. If it is stable (wrapped in useCallback), the effect runs once.
Setting the document title.
useEffect(() => {
document.title = `${count} clicks`;
}, [count]);
The title updates whenever count changes.
The infinite loop pitfall. An effect that updates state which is in its dependency array causes an infinite loop.
// Infinite loop
useEffect(() => {
setCount(count + 1);
}, [count]);
The effect runs, updates count, which triggers the effect again, which updates count again. The fix is to not include the state in the dependency array if the effect should not re-run when it changes, or to use the functional update form to avoid referencing the current value.
// No loop
useEffect(() => {
setCount((c) => c + 1);
}, []);
The stale closure pitfall. An effect that captures a value from the render in which it ran will use that value even after the value has changed, unless the effect re-runs with new dependencies.
// Stale closure
useEffect(() => {
const id = setInterval(() => {
console.log(count); // always the initial count
}, 1000);
return () => clearInterval(id);
}, []); // count not in dependencies
The interval logs the initial count on every tick because the effect captured it. The fix is to add count to the dependency array, which re-creates the interval whenever count changes, or to use a ref that always holds the current value.
// With ref
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current); // always current
}, 1000);
return () => clearInterval(id);
}, []);
The ref is updated on every render, and the interval reads from the ref, so it always sees the current value.
The missing dependency pitfall. Omitting a dependency that the effect uses causes the effect to run with stale values. The ESLint plugin eslint-plugin-react-hooks warns about missing dependencies. The warning should be addressed, not suppressed, unless the omission is deliberate and understood.
Complete Example Session
// ============================================
// PART 1: BASIC useEffect
// ============================================
import { useEffect } from 'react';
function Component() {
useEffect(() => {
console.log('Effect ran');
});
return <div>Hello</div>;
}
// Runs after every render
// ============================================
// PART 2: EMPTY DEPENDENCY ARRAY
// ============================================
useEffect(() => {
console.log('Mounted');
}, []);
// Runs once on mount
// ============================================
// PART 3: DEPENDENCY ARRAY WITH VALUE
// ============================================
useEffect(() => {
console.log(`Count is ${count}`);
}, [count]);
// Runs on mount and when count changes
// ============================================
// PART 4: CLEANUP FUNCTION
// ============================================
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
// ============================================
// PART 5: FETCHING DATA
// ============================================
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true;
};
}, [userId]);
if (!user) return <p>Loading…</p>;
return <p>{user.name}</p>;
}
// ============================================
// PART 6: FETCH WITH ABORT CONTROLLER
// ============================================
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') console.error(err);
});
return () => controller.abort();
}, [userId]);
// ============================================
// PART 7: EVENT LISTENER
// ============================================
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// ============================================
// PART 8: DOCUMENT TITLE
// ============================================
useEffect(() => {
document.title = `${count} clicks`;
}, [count]);
// ============================================
// PART 9: INFINITE LOOP
// ============================================
// Bad
useEffect(() => {
setCount(count + 1);
}, [count]); // infinite loop
// Good
useEffect(() => {
setCount((c) => c + 1);
}, []);
// ============================================
// PART 10: STALE CLOSURE WITH REF
// ============================================
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current); // always current
}, 1000);
return () => clearInterval(id);
}, []);
The ten parts covered basic useEffect, empty dependency array, dependency array with value, cleanup function, fetching data, fetch with AbortController, event listener, document title, infinite loop, and stale closure with ref.
Quick Reference
useEffect Syntax
| Form | Behavior |
|---|---|
useEffect(() => {}) | After every render |
useEffect(() => {}, []) | Once on mount |
useEffect(() => {}, [a, b]) | When a or b change |
useEffect(() => { return cleanup }, []) | Cleanup on unmount |
Common Effects
| Effect | Cleanup |
|---|---|
setInterval | clearInterval(id) |
setTimeout | clearTimeout(id) |
addEventListener | removeEventListener |
WebSocket | ws.close() |
IntersectionObserver | observer.disconnect() |
fetch | AbortController.abort() |
Dependency Array Rules
| Rule | Consequence |
|---|---|
| Include all values used in the effect | Prevents stale closures |
| Omit the array | Runs after every render |
| Empty array | Runs once |
| Include state updated by the effect | Infinite loop |
| Include stable values | No re-run |
Pitfalls
| Pitfall | Cause | Fix |
|---|---|---|
| Infinite loop | Effect updates state in dependencies | Remove from dependencies or use functional update |
| Stale closure | Missing dependency | Add to dependencies or use ref |
| Memory leak | No cleanup | Return cleanup function |
| Double fetch | Effect runs twice in StrictMode | Expected in development, harmless |
| Set state after unmount | No cancellation | Use AbortController or cancelled flag |
Best Practices
✅ Do This:
// Include all dependencies
useEffect(() => {
fetchData(userId);
}, [userId]); // ✅
// Return a cleanup function for subscriptions
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []); // ✅
// Use AbortController for fetches
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal });
return () => controller.abort();
}, [url]); // ✅
// Use functional updates to avoid dependency on state
useEffect(() => {
setCount((c) => c + 1);
}, []); // ✅
// Use a ref for values that should not trigger re-runs
const ref = useRef(value);
useEffect(() => { ref.current = value; }, [value]); // ✅
// Use the eslint-plugin-react-hooks rule
// It warns about missing dependencies // ✅
❌ Don’t Do This:
// Don't omit dependencies that the effect uses
useEffect(() => {
fetchData(userId); // userId not in deps // ❌
}, []);
// Don't update state that is in the dependency array
useEffect(() => {
setCount(count + 1); // ❌ infinite
}, [count]);
// Don't forget cleanup for subscriptions
useEffect(() => {
window.addEventListener('resize', handleResize); // ❌ leak
}, []);
// Don't pass an async function directly
useEffect(async () => { // ❌ returns Promise
await fetchData();
}, []);
// Don't suppress the dependency lint rule
// eslint-disable-next-line react-hooks/exhaustive-deps // ⚠️
// Don't read ref.current before mount
useEffect(() => {
console.log(ref.current.value); // ref.current null // ⚠️
}, []);
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Infinite loop | Effect updates state in deps | Remove or use functional update |
| Stale closure | Missing dependency | Add dependency or use ref |
| Memory leak | No cleanup | Return cleanup function |
setState on unmounted | Fetch resolves after unmount | Use AbortController or flag |
| Effect runs twice on mount | StrictMode double-invokes | Expected in development |
ref.current is null | Read before mount | Read after mount |
| Missing dependency warning | Value used but not listed | Add to dependency array |
Real-World Examples
1. Fetch on Mount
useEffect(() => {
fetch('/api/data').then((r) => r.json()).then(setData);
}, []);
2. Fetch on Prop Change
useEffect(() => {
fetch(`/api/users/${userId}`).then((r) => r.json()).then(setUser);
}, [userId]);
3. Window Resize
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
4. Timer
useEffect(() => {
const id = setInterval(() => setCount((c) => c + 1), 1000);
return () => clearInterval(id);
}, []);
5. Document Title
useEffect(() => {
document.title = title;
}, [title]);
6. Local Storage Sync
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
7. Keyboard Shortcut
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
8. WebSocket Connection
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (e) => setMessage(e.data);
return () => ws.close();
}, [url]);
9. Intersection Observer
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
10. Debounced Search
useEffect(() => {
const id = setTimeout(() => search(query), 300);
return () => clearTimeout(id);
}, [query]);
Visual
useEffect Lifecycle
┌─────────────────────────────────────────────────────────────┐
│ useEffect LIFECYCLE │
│ │
│ Render commits to DOM │
│ │ │
│ ▼ │
│ Effect runs │
│ │ │
│ ▼ │
│ Dependencies change? │
│ │ │
│ ├── NO ──▶ Effect does not re-run │
│ │ │
│ └── YES │
│ │ │
│ ▼ │
│ Cleanup runs (from previous effect) │
│ │ │
│ ▼ │
│ Effect runs again │
│ │ │
│ ▼ │
│ On unmount: cleanup runs │
│ │
└─────────────────────────────────────────────────────────────┘
Dependency Array Behavior
┌─────────────────────────────────────────────────────────────┐
│ NO ARRAY │
│ useEffect(() => { ... }) │
│ Runs: after every render │
│ │
│ EMPTY ARRAY │
│ useEffect(() => { ... }, []) │
│ Runs: once on mount │
│ │
│ WITH VALUES │
│ useEffect(() => { ... }, [a, b]) │
│ Runs: on mount + when a or b changes │
│ │
└─────────────────────────────────────────────────────────────┘
Cleanup Timing
┌─────────────────────────────────────────────────────────────┐
│ COMPONENT MOUNTS │
│ Effect runs → subscribes │
│ │
│ DEPENDENCY CHANGES │
│ Cleanup runs → unsubscribes (old) │
│ Effect runs → subscribes (new) │
│ │
│ COMPONENT UNMOUNTS │
│ Cleanup runs → unsubscribes │
│ │
│ Cleanup always runs before the next effect and on unmount. │
│ │
└─────────────────────────────────────────────────────────────┘
Infinite Loop
┌─────────────────────────────────────────────────────────────┐
│ BAD: EFFECT UPDATES STATE IN DEPS │
│ │
│ useEffect(() => { │
│ setCount(count + 1); │
│ }, [count]); │
│ │
│ Render → Effect → setCount → Render → Effect → ... │
│ │
│ Each render updates count, which triggers the effect, │
│ which updates count again. Infinite loop. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ GOOD: FUNCTIONAL UPDATE, EMPTY DEPS │
│ │
│ useEffect(() => { │
│ setCount((c) => c + 1); │
│ }, []); │
│ │
│ Runs once. No dependency on count. No loop. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Hook | useEffect(effect, deps) |
| Runs | After render commits |
| No deps | After every render |
| Empty deps | Once on mount |
| With deps | When deps change |
| Cleanup | Returned function |
| Cleanup timing | Before next effect, on unmount |
| Async | Define inner async function |
| Abort | AbortController in cleanup |
| Common effects | Fetch, subscribe, timer, title |
| Common pitfalls | Infinite loop, stale closure, leak |
Key takeaways:
- Side effects belong outside the render phase. A component should be a pure function of props and state. Fetching, subscribing, and DOM manipulation are side effects that run after the render is committed.
useEffectruns after the render is committed to the DOM. It does not run during render. This ensures the DOM is available for the effect and prevents side effects from running for renders that are discarded.- The dependency array controls when the effect runs. No array means after every render. An empty array means once on mount. An array of values means when any of those values change.
- The cleanup function runs before the next effect and on unmount. It releases resources: timers, subscriptions, event listeners, and network requests. Every effect that creates a resource should return a cleanup function.
- The dependency array must include every value the effect uses. Omitting a value causes a stale closure: the effect uses the value from the render in which it ran, not the current value. The ESLint plugin warns about this.
- An effect that updates state which is in its dependency array causes an infinite loop. The effect runs, updates the state, which triggers the effect again. Use the functional update form or remove the state from the dependencies.
- Async effects use an inner async function.
useEffectdoes not accept an async function directly because async functions return a promise, and the effect must return either nothing or a cleanup function. Define the async function inside and call it. AbortControllercancels fetches on unmount or dependency change. The cleanup aborts the request, preventing state updates on unmounted components and avoiding race conditions.
Remember: useEffect is the hook that connects a React component to the world outside React. It is where data is fetched, events are subscribed, timers are set, and the DOM is synchronized. The dependency array is the control: it tells React what the effect depends on, and React re-runs the effect when those dependencies change. The cleanup function is the safety net: it undoes what the effect did. Getting these three parts right—the effect, the dependencies, and the cleanup—is most of what there is to know about useEffect. The pitfalls are all variations of getting one of them wrong: a missing dependency, a missing cleanup, or an effect that updates its own dependencies. Learn the patterns and the pitfalls, and side effects become predictable instead of mysterious.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!