What Is useEffect?
useEffect is React's hook for performing side effects in function components. A side effect is anything that interacts with the outside world โ fetching data, subscribing to events, manipulating the DOM directly, setting timers, or writing to localStorage.
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
if (!user) return <p>Loading...</p>;
return <h1>{user.name}</h1>;
}
The key idea: rendering should be pure. Your component function should only calculate JSX โ no side effects during render. useEffect lets you say "run this code after the render is committed to the DOM."
Effect vs Render
Understanding when code runs is fundamental to using useEffect correctly.
Component function called
|
v
Render phase (pure)
- Calculate JSX
- No side effects
- May be called multiple times (Strict Mode)
|
v
Commit phase
- React updates the DOM
|
v
Effects phase
- useEffect callbacks run
- Cleanup from previous effects runs first
- Browser has painted
function EffectTiming() {
console.log('1. Render: component function body');
useEffect(() => {
console.log('3. Effect: runs AFTER DOM update and paint');
return () => {
console.log('2. Cleanup: runs BEFORE next effect (or unmount)');
};
});
return <div>Check console</div>;
}
// First render output:
// 1. Render: component function body
// 3. Effect: runs AFTER DOM update and paint
// On re-render:
// 1. Render: component function body
// 2. Cleanup: runs BEFORE next effect
// 3. Effect: runs AFTER DOM update and paint
useEffect vs useLayoutEffect
| useEffect | useLayoutEffect | |
|---|---|---|
| When it runs | After paint | Before paint |
| Blocks painting? | No | Yes |
| Use for | Data fetching, subscriptions, logging | DOM measurements, scroll position, visual updates |
| Performance | Better (non-blocking) | Worse if overused (blocks visual update) |
// useLayoutEffect โ runs synchronously after DOM mutation, before paint
// Use when you need to measure or modify DOM before user sees it
import { useLayoutEffect, useRef, useState } from 'react';
function Tooltip({ text, targetRef }) {
const tooltipRef = useRef(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
// Measure DOM before paint to avoid flicker
const targetRect = targetRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
setPosition({
top: targetRect.top - tooltipRect.height - 8,
left: targetRect.left + (targetRect.width - tooltipRect.width) / 2,
});
}, [targetRef]);
return (
<div
ref={tooltipRef}
style={{ position: 'fixed', top: position.top, left: position.left }}
>
{text}
</div>
);
}
The Dependency Array
The dependency array is the second argument to useEffect. It controls when the effect runs.
No Dependency Array โ Runs After Every Render
useEffect(() => {
console.log('Runs after every render');
});
Empty Array โ Runs Only on Mount
useEffect(() => {
console.log('Runs once, after first render');
}, []);
With Dependencies โ Runs When Dependencies Change
useEffect(() => {
console.log('Runs when userId or role changes');
fetchUserData(userId, role);
}, [userId, role]);
Dependency Array Comparison
Render 1: useEffect(fn, [1, 'admin'])
-> Effect runs (first render)
Render 2: useEffect(fn, [1, 'admin'])
-> Effect SKIPPED (deps unchanged: 1===1, 'admin'==='admin')
Render 3: useEffect(fn, [2, 'admin'])
-> Effect RUNS (deps changed: 1!==2)
Render 4: useEffect(fn, [2, 'user'])
-> Effect RUNS (deps changed: 'admin'!=='user')
React uses Object.is to compare each dependency with its previous value. For primitives this is value comparison. For objects and arrays, this is reference comparison.
Reference Comparison Gotcha
function SearchResults({ query }) {
// BUG: new object created every render, effect runs every time
const options = { limit: 10, sort: 'date' };
useEffect(() => {
fetchResults(query, options);
}, [query, options]); // options is a new object every render!
}
// Fix 1: Move object outside component (if it's static)
const OPTIONS = { limit: 10, sort: 'date' };
function SearchResults({ query }) {
useEffect(() => {
fetchResults(query, OPTIONS);
}, [query]); // OPTIONS is the same reference
}
// Fix 2: useMemo for dynamic objects
function SearchResults({ query, sortOrder }) {
const options = useMemo(() => ({ limit: 10, sort: sortOrder }), [sortOrder]);
useEffect(() => {
fetchResults(query, options);
}, [query, options]); // options only changes when sortOrder changes
}
// Fix 3: Use the primitive values directly
function SearchResults({ query, sortOrder }) {
useEffect(() => {
fetchResults(query, { limit: 10, sort: sortOrder });
}, [query, sortOrder]); // primitives in deps
}
Missing Dependencies
React's react-hooks/exhaustive-deps ESLint rule will warn you about missing dependencies. Don't ignore these warnings. Missing dependencies cause stale closures โ your effect reads old values.
The Problem
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection(roomId);
connection.on('message', (msg) => {
// BUG: messages is stale (captured from initial render)
setMessages([...messages, msg]);
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
// ESLint warns: 'messages' is missing from dependency array
}
The Fix: Functional Updates
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection(roomId);
connection.on('message', (msg) => {
// CORRECT: functional update doesn't need messages in deps
setMessages(prev => [...prev, msg]);
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // No lint warning โ messages not referenced
}
When a Dependency Changes Too Often
Sometimes a dependency changes on every render and you don't want the effect to re-run.
// Problem: onMessage prop changes every render (parent creates new function)
function ChatRoom({ roomId, onMessage }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.on('message', onMessage);
connection.connect();
return () => connection.disconnect();
}, [roomId, onMessage]); // Reconnects every render if onMessage is unstable
}
// Fix: Use useEffectEvent (React 19+) or ref pattern
function ChatRoom({ roomId, onMessage }) {
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage; // Always points to latest
useEffect(() => {
const connection = createConnection(roomId);
connection.on('message', (msg) => {
onMessageRef.current(msg); // Reads latest without being a dependency
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // Only reconnects when roomId changes
}
Cleanup Functions
The function returned from useEffect is the cleanup function. It runs:
- Before the effect re-runs (when dependencies change)
- When the component unmounts
When Cleanup Runs
Mount:
Effect runs -> return cleanup_v1
Update (deps changed):
cleanup_v1 runs -> (cleans up previous effect)
Effect runs -> return cleanup_v2
Update (deps changed again):
cleanup_v2 runs -> (cleans up previous effect)
Effect runs -> return cleanup_v3
Unmount:
cleanup_v3 runs -> (final cleanup)
Timer Cleanup
function Timer() {
const [seconds, setSeconds] = useState(0);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
if (!isRunning) return; // No effect, no cleanup needed
const intervalId = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
// Cleanup: clear interval when isRunning changes or component unmounts
return () => clearInterval(intervalId);
}, [isRunning]);
return (
<div>
<p>{seconds}s</p>
<button onClick={() => setIsRunning(r => !r)}>
{isRunning ? 'Pause' : 'Start'}
</button>
<button onClick={() => { setIsRunning(false); setSeconds(0); }}>
Reset
</button>
</div>
);
}
Subscription Cleanup
function WindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handleResize);
// Cleanup: remove listener to prevent memory leaks
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty deps โ set up once, clean up on unmount
return <p>{size.width} x {size.height}</p>;
}
WebSocket Cleanup
function LiveFeed({ channel }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/${channel}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setMessages(prev => [...prev, data]);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
// Cleanup: close WebSocket when channel changes or component unmounts
return () => {
ws.close();
};
}, [channel]);
return (
<ul>
{messages.map((msg, i) => (
<li key={i}>{msg.text}</li>
))}
</ul>
);
}
Multiple useEffects
You can (and should) use multiple useEffect calls to separate unrelated logic. Each effect handles one concern.
function Dashboard({ userId }) {
const [user, setUser] = useState(null);
const [notifications, setNotifications] = useState([]);
// Effect 1: Fetch user data when userId changes
useEffect(() => {
let cancelled = false;
fetchUser(userId).then(data => {
if (!cancelled) setUser(data);
});
return () => { cancelled = true; };
}, [userId]);
// Effect 2: Subscribe to notifications (independent concern)
useEffect(() => {
const unsubscribe = subscribeToNotifications(userId, (notification) => {
setNotifications(prev => [notification, ...prev]);
});
return () => unsubscribe();
}, [userId]);
// Effect 3: Update document title (independent concern)
useEffect(() => {
if (user) {
document.title = `${user.name}'s Dashboard`;
}
return () => { document.title = 'App'; };
}, [user]);
// Effect 4: Log analytics (independent concern)
useEffect(() => {
analytics.logPageView('dashboard', { userId });
}, [userId]);
return (
<div>
<h1>{user?.name}</h1>
<NotificationList items={notifications} />
</div>
);
}
Why Separate Effects?
BAD โ One giant effect mixing concerns:
useEffect(() => {
fetchUser(userId); // Fetch
subscribe(userId); // Subscribe
document.title = '...'; // DOM
analytics.log('...'); // Analytics
return () => {
unsubscribe();
document.title = 'App';
};
}, [userId]);
GOOD โ Separated by concern:
useEffect(() => { fetchUser(userId); }, [userId]);
useEffect(() => { subscribe(userId); return unsubscribe; }, [userId]);
useEffect(() => { document.title = '...'; }, [user]);
useEffect(() => { analytics.log('...'); }, [userId]);
Benefits:
- Each effect has its own cleanup
- Each can have different dependency arrays
- Easier to understand, test, and debug
- Can be extracted into custom hooks
Race Conditions
Race conditions happen when multiple async operations overlap and the results arrive out of order.
The Problem
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
// User types "ab" -> fetch starts for "ab"
// User types "abc" -> fetch starts for "abc"
// "abc" response arrives first -> setResults(abcResults)
// "ab" response arrives second -> setResults(abResults) <- STALE!
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
}, [query]);
return <ResultsList results={results} />;
}
Timeline of the race condition:
t=0 query="ab" -> fetch("/api/search?q=ab") starts
t=1 query="abc" -> fetch("/api/search?q=abc") starts
t=2 "abc" response arrives -> setResults(abcResults) // correct
t=3 "ab" response arrives -> setResults(abResults) // STALE! overwrites abc
Fix 1: Boolean Flag (Simple)
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
let cancelled = false;
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => {
if (!cancelled) {
setResults(data); // Only update if this effect hasn't been cleaned up
}
});
return () => {
cancelled = true; // Mark previous request as stale
};
}, [query]);
return <ResultsList results={results} />;
}
Fix 2: AbortController (Recommended)
AbortController actually cancels the network request, saving bandwidth and processing.
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
setResults(data);
setLoading(false);
})
.catch(err => {
if (err.name === 'AbortError') {
// Request was cancelled โ not an error
return;
}
setError(err.message);
setLoading(false);
});
return () => controller.abort(); // Cancel request on cleanup
}, [query]);
if (error) return <p>Error: {error}</p>;
if (loading) return <p>Loading...</p>;
return <ResultsList results={results} />;
}
With AbortController:
t=0 query="ab" -> fetch starts, controller_1 created
t=1 query="abc" -> controller_1.abort() called (cancels "ab" request)
-> fetch starts, controller_2 created
t=2 "abc" response arrives -> setResults(abcResults)
t=3 "ab" response NEVER arrives (aborted)
Fix 3: AbortController with Async/Await
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
async function fetchUser() {
setLoading(true);
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = await res.json();
setUser(data);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Fetch failed:', err);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
fetchUser();
return () => controller.abort();
}, [userId]);
if (loading) return <p>Loading...</p>;
return <h1>{user?.name}</h1>;
}
Event Listener Lifecycle
Managing event listeners in React requires careful cleanup to avoid memory leaks and stale references.
Window/Document Events
function KeyboardShortcuts({ onSave, onUndo }) {
useEffect(() => {
const handleKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
onSave();
}
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault();
onUndo();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onSave, onUndo]);
return null;
}
Scroll Tracking
function ScrollProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
const handleScroll = () => {
const scrollTop = document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = document.documentElement.clientHeight;
const scrolled = scrollTop / (scrollHeight - clientHeight);
setProgress(Math.round(scrolled * 100));
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: `${progress}%`,
height: 3,
backgroundColor: '#3b82f6',
transition: 'width 100ms',
}}
/>
);
}
Intersection Observer
function LazyImage({ src, alt, placeholder }) {
const imgRef = useRef(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const element = imgRef.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.unobserve(element); // Stop observing once visible
}
},
{ threshold: 0.1 }
);
observer.observe(element);
return () => observer.disconnect();
}, []);
return (
<img
ref={imgRef}
src={isVisible ? src : placeholder}
alt={alt}
loading="lazy"
/>
);
}
Media Query Listener
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
return window.matchMedia(query).matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handler = (event) => setMatches(event.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
// Usage
function ResponsiveLayout() {
const isMobile = useMediaQuery('(max-width: 768px)');
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
return (
<div className={isMobile ? 'mobile-layout' : 'desktop-layout'}>
<p>Theme: {prefersDark ? 'dark' : 'light'}</p>
</div>
);
}
Performance: When Effects Run
Avoiding Unnecessary Effects
Many effects can be replaced with simpler patterns.
// BAD: Using effect to derive state
function FilteredList({ items, filter }) {
const [filtered, setFiltered] = useState([]);
useEffect(() => {
setFiltered(items.filter(item => item.category === filter));
}, [items, filter]);
// Problem: extra render โ first render shows stale data, then effect updates
return <List items={filtered} />;
}
// GOOD: Calculate during render
function FilteredList({ items, filter }) {
const filtered = items.filter(item => item.category === filter);
// No effect, no extra render, always in sync
return <List items={filtered} />;
}
// GOOD: useMemo if filtering is expensive
function FilteredList({ items, filter }) {
const filtered = useMemo(
() => items.filter(item => item.category === filter),
[items, filter]
);
return <List items={filtered} />;
}
You Might Not Need an Effect
| Instead of this effect... | Do this instead |
|---|---|
| Transform data for rendering | Calculate during render |
| Filter/sort a list | useMemo during render |
| Reset state when prop changes | Use a key prop on the component |
| Update parent state | Call parent's callback in event handler |
| Synchronize two state variables | Derive one from the other |
| Initialize from props | Use state initializer function |
| POST on form submit | Handle in submit event handler |
// BAD: Effect to reset form when userId changes
function Profile({ userId }) {
const [name, setName] = useState('');
useEffect(() => {
setName(''); // Reset on userId change
}, [userId]);
}
// GOOD: Use key to remount
function ProfilePage({ userId }) {
return <ProfileForm key={userId} userId={userId} />;
// When userId changes, React unmounts old ProfileForm and mounts new one
// State automatically resets
}
Debouncing Effects
function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const timeoutId = setTimeout(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
}, 300);
return () => clearTimeout(timeoutId);
// Every keystroke clears the previous timeout
// Only the last one fires after 300ms of inactivity
}, [query]);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>
{results.map(r => <li key={r.id}>{r.name}</li>)}
</ul>
</div>
);
}
Debounce timeline:
t=0 type 'r' -> setTimeout(fetch, 300)
t=100 type 'e' -> clearTimeout, setTimeout(fetch, 300)
t=200 type 'a' -> clearTimeout, setTimeout(fetch, 300)
t=300 type 'c' -> clearTimeout, setTimeout(fetch, 300)
t=400 type 't' -> clearTimeout, setTimeout(fetch, 300)
t=700 (300ms idle) -> fetch('/api/search?q=react') fires
Custom Hooks with useEffect
useFetch
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!url) {
setData(null);
setLoading(false);
return;
}
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
setLoading(false);
}
});
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// Usage
function UserList() {
const { data: users, loading, error } = useFetch('/api/users');
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
useDocumentTitle
function useDocumentTitle(title) {
useEffect(() => {
const previousTitle = document.title;
document.title = title;
return () => {
document.title = previousTitle;
};
}, [title]);
}
// Usage
function ProductPage({ product }) {
useDocumentTitle(`${product.name} | Store`);
return <h1>{product.name}</h1>;
}
useOnClickOutside
function useOnClickOutside(ref, handler) {
useEffect(() => {
const listener = (event) => {
if (!ref.current || ref.current.contains(event.target)) {
return; // Click was inside the element
}
handler(event);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
// Usage
function Dropdown() {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
useOnClickOutside(dropdownRef, () => setIsOpen(false));
return (
<div ref={dropdownRef}>
<button onClick={() => setIsOpen(o => !o)}>Menu</button>
{isOpen && (
<ul className="dropdown-menu">
<li>Option 1</li>
<li>Option 2</li>
<li>Option 3</li>
</ul>
)}
</div>
);
}
useInterval
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
// Always point to the latest callback
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return; // Paused
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
// Usage
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const [isRunning, setIsRunning] = useState(false);
useInterval(() => {
setSeconds(s => s + 1);
}, isRunning ? 1000 : null); // null = paused
return (
<div>
<p>{seconds}s</p>
<button onClick={() => setIsRunning(r => !r)}>
{isRunning ? 'Pause' : 'Start'}
</button>
<button onClick={() => { setIsRunning(false); setSeconds(0); }}>
Reset
</button>
</div>
);
}
Strict Mode and Double Effects
In development with React.StrictMode, effects run twice (mount -> unmount -> mount). This helps catch bugs โ if your cleanup is correct, double-running should be safe.
// This breaks in Strict Mode if cleanup is wrong:
useEffect(() => {
const connection = createConnection();
connection.connect();
// No cleanup! In Strict Mode: two connections opened, none closed
}, []);
// This works in Strict Mode:
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => connection.disconnect(); // Proper cleanup
}, []);
Strict Mode lifecycle:
Mount:
Effect runs -> connection.connect()
Cleanup runs -> connection.disconnect()
Effect runs again -> connection.connect()
Unmount:
Cleanup runs -> connection.disconnect()
Result: One connection open at a time. Correct!
Common Mistakes
1. Infinite Loop
// BUG: Creates a new array every render -> effect re-runs -> setState -> re-render
function Broken() {
const [items, setItems] = useState([]);
useEffect(() => {
setItems([1, 2, 3]); // New array -> re-render -> effect runs again -> ...
}); // No dependency array = runs every render
}
// FIX: Add dependency array
useEffect(() => {
setItems([1, 2, 3]);
}, []); // Only on mount
2. Missing Cleanup
// BUG: Memory leak โ listener never removed
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []);
// FIX: Return cleanup function
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
3. Async Effect Function
// BUG: useEffect callback can't be async (returns a Promise, not a cleanup function)
useEffect(async () => {
const data = await fetch('/api/data');
// ...
}, []);
// FIX: Define async function inside effect
useEffect(() => {
async function fetchData() {
const data = await fetch('/api/data');
// ...
}
fetchData();
}, []);
4. Object/Array in Dependencies
// BUG: New object every render -> effect runs every render
function Component({ userId }) {
const config = { userId, limit: 10 };
useEffect(() => {
fetchData(config);
}, [config]); // config is new object every render
}
// FIX: Use primitive values as dependencies
function Component({ userId }) {
useEffect(() => {
fetchData({ userId, limit: 10 });
}, [userId]); // Only re-runs when userId changes
}
Effect Dependency Cheat Sheet
useEffect(fn) -> Run after EVERY render
useEffect(fn, []) -> Run once after MOUNT only
useEffect(fn, [a, b]) -> Run after mount AND when a or b changes
useEffect(fn => cleanup) -> Cleanup runs before re-run and on unmount
Dependency values:
Primitives (string, number, boolean) -> compared by value
Objects, arrays, functions -> compared by reference
Refs (useRef) -> NEVER put in deps (stable reference)
setState functions -> NEVER put in deps (stable reference)
Key Takeaways
- useEffect runs after render โ it's for side effects, not for computing derived state
- The dependency array controls when โ empty means mount only, filled means "re-run when these change"
- Always clean up โ subscriptions, timers, event listeners, and connections
- Use AbortController for fetch requests to prevent race conditions
- Separate concerns โ use multiple useEffects for unrelated logic
- Don't ignore lint warnings about missing dependencies
- Functional state updates avoid the need to include state in dependencies
- Many effects can be eliminated โ calculate during render, use keys, or handle in event handlers
- Strict Mode double-fires effects โ this is intentional and helps you find cleanup bugs
- Extract custom hooks when effect patterns repeat (
useFetch,useInterval,useOnClickOutside)