React useEffect Hook — When and How to Use
Master React useEffect hook. Learn side effects, dependency arrays, cleanup functions, common pitfalls, and when to use useEffect vs other hooks.
React useEffect Hook — When and How to Use
useEffect is how you run side effects in React functional components. Side effects are things that happen "outside" the component — API calls, subscriptions, DOM manipulation, timers.
Basic Syntax
import { useEffect } from "react";
useEffect(() => {
// Side effect code here
console.log("Component rendered!");
}, []);
The function you pass is the effect. The array at the end is the dependency array.
The Dependency Array
The dependency array controls WHEN the effect runs:
// Runs after EVERY render
useEffect(() => {
console.log("Runs every time");
});
// Runs only on FIRST render (mount)
useEffect(() => {
console.log("Runs once");
}, []);
// Runs when `count` changes
useEffect(() => {
console.log("Count changed to:", count);
}, [count]);
// Runs when `name` OR `age` changes
useEffect(() => {
console.log("Name or age changed");
}, [name, age]);
Think of it like this: "Run this effect whenever these values change."
Cleanup Function
The function you return from useEffect is the cleanup — it runs before the next effect or when the component unmounts:
useEffect(() => {
const handleResize = () => console.log(window.innerWidth);
window.addEventListener("resize", handleResize);
// Cleanup — remove the listener
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
Without cleanup, you'd keep adding event listeners on every render — a memory leak.
When Cleanup Runs
- Before the next effect (if dependencies changed)
- On unmount (component removed from DOM)
useEffect(() => {
console.log(`Subscribed to user ${userId}`);
return () => {
console.log(`Unsubscribed from user ${userId}`);
};
}, [userId]);
// When userId changes: "Unsubscribed from user 1" → "Subscribed to user 2"
Common Use Cases
Fetching Data
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function fetchUser() {
setLoading(true);
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
if (!cancelled) {
setUser(data);
setLoading(false);
}
}
fetchUser();
return () => { cancelled = true; };
}, [userId]);
if (loading) return <p>Loading...</p>;
return <h1>{user.name}</h1>;
}
The cancelled flag prevents setting state on an unmounted component.
Keyboard Events
useEffect(() => {
function handleKeyDown(e) {
if (e.key === "Escape") {
onClose();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
Timer / Interval
useEffect(() => {
const timer = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
Updating Document Title
useEffect(() => {
document.title = `${count} new messages`;
}, [count]);
Common Mistakes
1. Missing Dependencies
// Bug — `count` is used but not in dependency array
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // Always logs initial value!
}, 1000);
return () => clearInterval(timer);
}, []); // ← missing `count`
// Fix — use functional update
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1); // No dependency needed!
}, 1000);
return () => clearInterval(timer);
}, []);
2. Object Dependencies Causing Infinite Loops
// Infinite loop! {} !== {} on every render
useEffect(() => {
fetchData(options);
}, [options]); // options is recreated every render
// Fix — memoize or use primitive values
const optionsKey = `${options.page}-${options.sort}`;
useEffect(() => {
fetchData(options);
}, [optionsKey]);
3. Async Directly in useEffect
// Wrong — useEffect can't be async
useEffect(async () => {
const data = await fetchData(); // Don't do this!
}, []);
// Correct — define async function inside
useEffect(() => {
async function load() {
const data = await fetchData();
setData(data);
}
load();
}, []);
useEffect Timing
Effects run after the browser paints. This means:
- The UI updates first (users see the new state)
- Then the effect runs
If you need to run code before paint (e.g., measuring DOM), use useLayoutEffect instead.
When NOT to Use useEffect
Not everything needs useEffect. Ask yourself: "Is this a side effect, or just a computation?"
Don't use useEffect for:
// Transforming data — just compute it inline
const filtered = items.filter(i => i.active);
// Handling events — use event handlers
<button onClick={() => setCount(c + 1)}>
// Initializing state — use useState with initializer
const [data] = useState(() => computeInitial());
Do use useEffect for:
- API calls and data fetching
- Subscriptions (WebSocket, event listeners)
- Timers and intervals
- Direct DOM manipulation
- Syncing with external systems
Key Takeaways
- useEffect runs side effects — things outside React's render cycle
- Dependency array controls when:
[]= mount only,[x]= when x changes - Always clean up subscriptions, listeners, and timers
- Don't use useEffect for computations — just compute inline
- Use a cancelled/aborted flag to prevent state updates after unmount
- When in doubt about dependencies, trust the ESLint exhaustive-deps rule
🚀 Practice What You Learned
Apply these concepts with hands-on coding challenges: