Component Lifecycle and Effects
The Three Phases of a Component's Life
Every React component goes through three phases:
- Mount — Component appears in the tree for the first time
- Update — Props or state change, component re-renders
- Unmount — Component is removed from the tree
In class components, these mapped to explicit lifecycle methods (componentDidMount, componentDidUpdate, componentWillUnmount). In function components, useEffect handles all three.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// Runs after mount AND after userId changes
let cancelled = false;
async function fetchUser() {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (!cancelled) {
setUser(data);
}
}
fetchUser();
return () => {
// Cleanup: runs before next effect AND on unmount
cancelled = true;
};
}, [userId]);
if (!user) return <Skeleton />;
return <div>{user.name}</div>;
}
Think of useEffect as a synchronization mechanism, not a lifecycle hook. It synchronizes your component with an external system — the network, the DOM, a timer, a subscription. The dependency array tells React: "re-synchronize whenever these values change." The cleanup function tells React: "here is how to undo the previous synchronization before starting a new one."
When useEffect Runs
useEffect runs after the browser paints. This is critical timing:
Render → Commit (DOM update) → Browser Paint → useEffect
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
// This runs AFTER the browser has painted count to the screen.
// The user sees the updated count before this code runs.
console.log('Effect: count is', count);
});
console.log('Render: count is', count);
return <div>{count}</div>;
}
// Output order on mount:
// "Render: count is 0"
// (browser paints "0" on screen)
// "Effect: count is 0"
This timing means the user never sees an intermediate state caused by the effect. The screen is already painted when the effect runs.
The Dependency Array
The dependency array controls when the effect re-runs:
// Runs after EVERY render
useEffect(() => { /* ... */ });
// Runs only on mount (empty deps)
useEffect(() => { /* ... */ }, []);
// Runs on mount and whenever userId or query changes
useEffect(() => { /* ... */ }, [userId, query]);
The empty dependency array [] does not mean "run once." It means "this effect has no dependencies — it synchronizes with nothing that changes." If you find yourself adding // eslint-disable-next-line react-hooks/exhaustive-deps to suppress the linter, your effect is almost certainly broken. The linter knows which values from the closure the effect uses. If the effect reads userId but userId is not in the deps array, the effect captures a stale userId forever.
Cleanup: The Most Misunderstood Part
The cleanup function returned from useEffect runs:
- Before the next effect (when dependencies change)
- On unmount (when the component leaves the tree)
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
console.log('Connected to', roomId);
return () => {
connection.disconnect();
console.log('Disconnected from', roomId);
};
}, [roomId]);
return <Chat />;
}
// User switches from room "general" to room "react":
// 1. "Connected to general" (mount)
// 2. "Disconnected from general" (cleanup of previous effect)
// 3. "Connected to react" (new effect)
Strict Mode double-invocation
In development with Strict Mode, React mounts every component twice: mount → unmount → mount. This means your effect runs, cleanup runs, then the effect runs again. This is intentional — it catches effects that do not clean up properly. If your component breaks on the second mount, your cleanup is incomplete. In production, components mount once. This double-invocation only happens in development.
The Infinite Loop Trap
The most common useEffect bug:
// INFINITE LOOP:
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => setUsers(data)); // Triggers re-render
}); // No dependency array → runs after EVERY render
// setUsers causes re-render → effect runs again → setUsers → re-render → ...
}
// Fix: Add dependency array
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => setUsers(data));
}, []); // Only on mount
Another subtle infinite loop:
// ALSO INFINITE LOOP:
function Search({ query }) {
const [results, setResults] = useState([]);
const options = { query, limit: 10 }; // New object every render!
useEffect(() => {
fetchResults(options).then(setResults);
}, [options]); // options is a new reference every render → effect re-runs → infinite
// Fix: Depend on primitive values, not object references
useEffect(() => {
fetchResults({ query, limit: 10 }).then(setResults);
}, [query]); // Stable primitive dependency
}
Production Scenario: Race Condition Prevention
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
let ignore = false;
async function search() {
const data = await fetchResults(query);
if (!ignore) {
setResults(data);
}
}
search();
return () => {
ignore = true; // Prevent stale response from overwriting fresh data
};
}, [query]);
return <ResultList results={results} />;
}
// User types "re" → "rea" → "reac" → "react" quickly
// Each keystroke triggers a new effect, which cancels the previous.
// Without the ignore flag, a slow "re" response arriving after a fast
// "react" response would overwrite the correct results.
-
Wrong: Using useEffect for derived state: useEffect(() => setFullName(first + last), [first, last]) Right: Compute during render: const fullName = first + ' ' + last
-
Wrong: Missing dependency array — effect runs on every render Right: Always include the dependency array. Use [] for mount-only, or list all values the effect reads.
-
Wrong: Suppressing the exhaustive-deps lint rule Right: Fix the effect structure — restructure to include all dependencies, or move logic into the effect
-
Wrong: Not cleaning up subscriptions and timers Right: Return a cleanup function that unsubscribes, clears timers, or sets cancel flags
- 1useEffect runs AFTER browser paint — it is for synchronization with external systems, not for computed values
- 2The dependency array lists every value from the component scope that the effect reads — never lie to React about deps
- 3Cleanup runs before the next effect and on unmount — use it for subscriptions, timers, and cancel flags
- 4Derived state does not need useEffect — compute it during render
- 5Strict Mode double-invokes effects in development to catch missing cleanup