React components are pure functions of their props and state — given the same input, they produce the same output. But real applications need to do things beyond rendering: fetch data, set up timers, subscribe to WebSocket events, update the document title. These are side effects — operations that reach outside the component and affect the world. The useEffect hook is React’s designated place for side effects: it runs after the render is committed to the DOM, keeping the render function pure while still allowing necessary impure work.
The Three Forms of useEffect
import { useEffect, useState } from "react";
// ── Form 1: No dependency array — runs after EVERY render ─────────────────────
// Rarely correct — usually an unintentional infinite loop waiting to happen
useEffect(() => {
console.log("Component rendered");
// Runs after every render, including re-renders triggered by this effect!
});
// ── Form 2: Empty array [] — runs ONCE on mount, cleanup on unmount ────────────
// Correct for: one-time setup, initial data fetch, event listeners
useEffect(() => {
fetchInitialData();
document.title = "Blog - Home";
return () => {
// Cleanup runs when component unmounts
document.title = "Blog";
};
}, []); // ← empty array = only on mount/unmount
// ── Form 3: Array with values — runs when those values change ─────────────────
// Correct for: re-fetching when a param changes, reacting to prop changes
function PostFeed({ page, tag }) {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetchPosts({ page, tag }).then(setPosts);
// Re-runs whenever page or tag changes
}, [page, tag]); // ← dependency array
return <PostList posts={posts} />;
}
Note: The dependency array tells React “re-run this effect when any of these values change.” React uses shallow equality to compare dependencies — for primitive values (strings, numbers, booleans), this works as expected. For objects and arrays, the comparison is reference equality: a new array
[1, 2, 3] created on every render is never equal to the previous [1, 2, 3] even though the contents are the same. This is why passing an array or object literal directly in the dependency array often causes infinite re-fetch loops.Tip: ESLint’s
react-hooks/exhaustive-deps rule (included with eslint-plugin-react-hooks) warns when you use a value inside useEffect without listing it in the dependency array. Follow these warnings — omitting a dependency is almost always a bug that causes stale data. If the lint rule triggers for a function, either move the function inside the effect or wrap it in useCallback to stabilise its reference.Warning: Never put an effect inside a loop or condition. The rules of hooks apply to
useEffect just as they do to useState — it must be called unconditionally at the top level of the component. If you need different effects based on a condition, put the condition inside the effect function: useEffect(() => { if (isLoggedIn) { fetchUserData(); } }, [isLoggedIn]).Common useEffect Patterns
// ── Document title ────────────────────────────────────────────────────────────
function PostDetailPage({ post }) {
useEffect(() => {
if (post?.title) {
document.title = `${post.title} | Blog`;
}
return () => { document.title = "Blog"; };
}, [post?.title]);
return <article>...</article>;
}
// ── Scroll to top on page change ───────────────────────────────────────────────
function App() {
const { pathname } = useLocation(); // from React Router
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]); // scroll up whenever the route changes
}
// ── Window resize listener ────────────────────────────────────────────────────
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler); // cleanup!
}, []); // add listener once, remove on unmount
return width;
}
The Execution Order
React rendering and effect lifecycle:
1. Component function runs (render)
→ React calculates what the DOM should look like
2. React updates the DOM (commit)
3. useEffect runs (after paint)
→ Async: does not block the browser from painting
4. Component re-renders (state/prop changes)
→ Previous effect cleanup runs first
→ New effect runs after the new render
Cleanup timing:
- On component UNMOUNT: cleanup of the last effect runs
- On dependency CHANGE: cleanup of the previous effect runs
BEFORE the new effect runs
Common Mistakes
Mistake 1 — Missing dependency causes stale closure
❌ Wrong — setCount uses stale count value:
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count is always 0 — stale closure!
}, 1000);
return () => clearInterval(id);
}, []); // count is missing from deps!
✅ Correct — use functional update form or add count to deps:
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1); // ✓ functional form — always fresh value
}, 1000);
return () => clearInterval(id);
}, []); // empty array is now correct ✓
Mistake 2 — Object/array in dependency array (infinite loop)
❌ Wrong — new object on every render triggers infinite loop:
function Component({ userId }) {
useEffect(() => {
fetch(`/api/users/${userId}`);
}, [{ id: userId }]); // new object on every render → infinite loop!
✅ Correct — use the primitive value:
useEffect(() => {
fetch(`/api/users/${userId}`);
}, [userId]); // ✓ primitive string/number comparison works correctly
Quick Reference
| Dependency Array | When Effect Runs | Use For |
|---|---|---|
| Omitted | After every render | Rarely — usually a mistake |
[] |
Once (on mount) | One-time setup, initial fetch |
[val] |
When val changes |
Re-fetch when param changes |
[a, b] |
When a or b changes |
Multiple params |