useEffect — Side Effects & Lifecycle
useEffect is the most misunderstood hook. It's not a lifecycle method replacement — it's a synchronization mechanism. You use it to synchronize your component with external systems (APIs, DOM, subscriptions, timers). Understanding this model prevents the majority of React bugs.
useEffect as a Synchronization Tool
import { useState, useEffect } from 'react';
// Mental model: "when X changes, synchronize Y with the outside world"
// NOT: "do this after mount" / "do this before unmount"
// Example: synchronize document.title with document state
function PageTitle({ title }: { title: string }) {
useEffect(() => {
document.title = title; // side effect: touching outside React
return () => {
document.title = 'My App'; // cleanup: restore when component unmounts
};
}, [title]); // dependency: re-sync when title changes
return <h1>{title}</h1>;
}
// The three dependency array forms:
useEffect(() => { /* runs after EVERY render */ }); // no array
useEffect(() => { /* runs ONCE on mount */ }, []); // empty array
useEffect(() => { /* runs when a or b changes */ }, [a, b]); // with depsRace Conditions — The Most Common useEffect Bug
// When a user types quickly, multiple fetches fire
// The responses can arrive OUT OF ORDER — a stale response overwrites fresh data
// ❌ RACE CONDITION
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => setUser(data)); // ← might set STALE data if userId changed
}, [userId]);
}
// ✅ FIX 1: AbortController — cancel the previous request
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') throw err; // ignore expected abort
});
return () => controller.abort(); // cleanup: cancel when userId changes
}, [userId]);
}
// ✅ FIX 2: ignore flag — for non-abortable async operations
function useUser(userId: string) {
const [user, setUser] = useState(null);
useEffect(() => {
let ignore = false;
fetchUser(userId).then(data => {
if (!ignore) setUser(data); // only update if still relevant
});
return () => { ignore = true; };
}, [userId]);
return user;
}Cleanup Functions — Prevent Memory Leaks
// Every effect that sets up a subscription, timer, or listener
// MUST clean it up to prevent memory leaks and stale callbacks
// ✅ EventListener cleanup
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// ✅ Timer cleanup
useEffect(() => {
const id = setInterval(() => {
setSecond(s => s + 1);
}, 1000);
return () => clearInterval(id);
}, []);
// ✅ WebSocket cleanup
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/live');
ws.onmessage = (e) => setMessages(prev => [...prev, JSON.parse(e.data)]);
return () => ws.close();
}, []);
// ✅ Intersection Observer cleanup
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
setIsVisible(entry.isIntersecting);
});
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);Common Mistakes — useEffect
- Missing dependencies — omitting props or state from the dependency array causes the effect to capture stale closures; always use `eslint-plugin-react-hooks` exhaustive-deps rule
- Putting objects/arrays in the dependency array without memoizing them — `[{ id: 1 }]` creates a new object reference every render, making the effect fire every render; memoize with useMemo or useMemo outside the effect
- Using useEffect for computed values — if a value can be derived synchronously, compute it during render; useEffect + state for derived values causes an extra render and timing issues
- `useEffect(async () => {...})` — async functions return a Promise, and useEffect cleanup must return a function or nothing; wrap async logic in an inner function and call it
- Infinite loop: `useEffect(() => { setState(x); }, [state])` — updating state that's in the dependency array causes infinite re-render; always check if the state needs to be a dependency or if you need a condition guard
Tip
Tip
Practice useEffect Side Effects Lifecycle in small, isolated examples before integrating into larger projects. Breaking concepts into small experiments builds genuine understanding faster than reading alone.
Modern React: Use Hooks instead of class lifecycle methods
Practice Task
Note
Practice Task — (1) Write a working example of useEffect Side Effects Lifecycle from scratch without looking at notes. (2) Modify it to handle an edge case (empty input, null value, or error state). (3) Share your solution in the Priygop community for feedback.
Quick Quiz
Common Mistake
Warning
A common mistake with useEffect Side Effects Lifecycle is skipping edge case testing — empty inputs, null values, and unexpected data types. Always validate boundary conditions to write robust, production-ready react code.
Key Takeaways
- useEffect is the most misunderstood hook.
- Missing dependencies — omitting props or state from the dependency array causes the effect to capture stale closures; always use `eslint-plugin-react-hooks` exhaustive-deps rule
- Putting objects/arrays in the dependency array without memoizing them — `[{ id: 1 }]` creates a new object reference every render, making the effect fire every render; memoize with useMemo or useMemo outside the effect
- Using useEffect for computed values — if a value can be derived synchronously, compute it during render; useEffect + state for derived values causes an extra render and timing issues