Why React Hooks Are Tricky
React hooks introduce a new mental model where component state and effects are tied to function calls. Most bugs come from not fully understanding how closures, the dependency array, and React's render cycle interact.
This guide covers the most common mistakes and the fixes for each.
Mistake 1: Stale Closures in useEffect
The most common and hardest-to-debug React hook bug.
// ❌ Bug: count is stale — always 0 inside the interval
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log('Count:', count); // always 0
setCount(count + 1); // always sets to 1 (0 + 1)
}, 1000);
return () => clearInterval(interval);
}, []); // [] means "run once" — closes over count=0 forever
return <div>{count}</div>;
}
Fix 1: Use functional state updater
// ✅ Functional update doesn't need to close over count
useEffect(() => {
const interval = setInterval(() => {
setCount(prev => prev + 1); // always gets current value
}, 1000);
return () => clearInterval(interval);
}, []);
Fix 2: Add to dependency array (causes re-registration)
// ✅ Effect re-runs when count changes
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1); // now uses current count
}, 1000);
return () => clearInterval(interval);
}, [count]); // but interval is recreated on every count change
Fix 3: useRef for mutable values
// ✅ ref is always current, doesn't trigger re-render
function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const interval = setInterval(() => {
setCount(countRef.current + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return <div>{count}</div>;
}
Mistake 2: Incorrect useEffect Dependencies
ESLint's react-hooks/exhaustive-deps rule flags missing dependencies for a reason.
// ❌ Missing dependency: userId
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, []); // Bug: doesn't refetch when userId changes
return <div>{user?.name}</div>;
}
// ✅ Include userId in dependencies
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // refetches when userId changes
Object and function dependencies
// ❌ Bug: options object is recreated on every render
function DataFetcher({ userId }) {
const options = { headers: { 'X-User': userId } }; // new object each render
useEffect(() => {
fetch('/api/data', options).then(/* ... */);
}, [options]); // Infinite loop! options always changes
}
// ✅ Fix 1: Move object inside the effect
useEffect(() => {
const options = { headers: { 'X-User': userId } };
fetch('/api/data', options).then(/* ... */);
}, [userId]); // only depends on primitive
// ✅ Fix 2: useMemo to stabilize object
const options = useMemo(
() => ({ headers: { 'X-User': userId } }),
[userId]
);
Mistake 3: Infinite Re-render Loops
// ❌ Infinite loop: effect sets state, causing re-render, running effect again
function BadComponent() {
const [data, setData] = useState([]);
useEffect(() => {
setData([...data, 'item']); // triggers re-render → effect runs again
}); // No dependency array = runs after every render
return <div>{data.length}</div>;
}
// ❌ Infinite loop: object in deps recreated every render
function AlsoBad() {
const [result, setResult] = useState(null);
const config = { timeout: 5000 }; // new object every render
useEffect(() => {
fetch('/api', config).then(r => r.json()).then(setResult);
}, [config]); // config changes every render → infinite
}
// ✅ Fix: useMemo, useCallback, or move values into the effect
const config = useMemo(() => ({ timeout: 5000 }), []); // stable reference
Mistake 4: Mutating State Directly
React state updates only trigger re-renders when you replace the reference, not when you mutate in place.
// ❌ Mutation: React doesn't detect the change
const [items, setItems] = useState(['a', 'b', 'c']);
function addItem() {
items.push('d'); // mutates the array
setItems(items); // same reference! React bails out
}
// ✅ Create a new array
function addItem() {
setItems([...items, 'd']); // new reference → re-render
}
// ❌ Object mutation
const [user, setUser] = useState({ name: 'Alice', age: 30 });
user.name = 'Bob'; // mutating
setUser(user); // same reference, no re-render
// ✅ Spread to create new object
setUser({ ...user, name: 'Bob' });
Nested object updates
const [profile, setProfile] = useState({
user: { name: 'Alice', address: { city: 'NYC', zip: '10001' } }
});
// ❌ Bug: nested mutation
profile.user.address.city = 'LA';
setProfile(profile);
// ✅ Deep spread
setProfile({
...profile,
user: {
...profile.user,
address: {
...profile.user.address,
city: 'LA',
},
},
});
// ✅ Even better: use Immer
import { produce } from 'immer';
setProfile(produce(draft => {
draft.user.address.city = 'LA'; // Immer makes mutations safe
}));
Mistake 5: useCallback / useMemo Overuse
// ❌ Pointless memoization — more expensive than doing nothing
function SimpleComponent({ value }) {
// Wrapping a trivial function wastes memory and CPU
const add = useCallback((a, b) => a + b, []); // just write: const add = (a, b) => a + b
const doubled = useMemo(() => value * 2, [value]); // just write: const doubled = value * 2
}
// ✅ When useCallback actually helps: callback passed to a memoized child
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []); // stable reference → prevents MemoizedChild from re-rendering
return <MemoizedChild onClick={handleClick} />;
// ✅ When useMemo actually helps: expensive computation
const filteredItems = useMemo(
() => items.filter(item => item.category === category && item.price < maxPrice),
[items, category, maxPrice]
); // only recomputes when inputs change, not on every render
Mistake 6: Calling Hooks Conditionally
// ❌ Breaks Rules of Hooks
function BadComponent({ isLoggedIn }) {
if (isLoggedIn) {
const [user, setUser] = useState(null); // conditional hook call
}
// ...
}
// ✅ Always call hooks at top level, use conditions inside
function GoodComponent({ isLoggedIn }) {
const [user, setUser] = useState(null); // always called
useEffect(() => {
if (!isLoggedIn) return; // condition inside the effect
fetchUser().then(setUser);
}, [isLoggedIn]);
}
Mistake 7: Not Cleaning Up Effects
// ❌ Memory leak / race condition: state set on unmounted component
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data); // might run after component unmounts
});
}, [userId]);
}
// ✅ Cancel fetch on cleanup with AbortController
useEffect(() => {
const controller = new AbortController();
fetchUser(userId, { signal: controller.signal })
.then(setUser)
.catch(err => {
if (err.name !== 'AbortError') throw err; // ignore abort errors
});
return () => controller.abort(); // cleanup: cancel in-flight request
}, [userId]);
Mistake 8: useState with Complex Objects — Consider useReducer
// ❌ Multiple related state updates get out of sync
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
async function loadData() {
setLoading(true);
try {
const result = await fetch('/api');
setData(result); // these three updates cause 3 renders
setLoading(false);
setError(null);
} catch (err) {
setError(err);
setLoading(false);
}
}
// ✅ useReducer groups related state updates
const [state, dispatch] = useReducer(reducer, {
status: 'idle', // 'idle' | 'loading' | 'success' | 'error'
data: null,
error: null,
});
function reducer(state, action) {
switch (action.type) {
case 'FETCH_START': return { ...state, status: 'loading', error: null };
case 'FETCH_SUCCESS': return { status: 'success', data: action.payload, error: null };
case 'FETCH_ERROR': return { ...state, status: 'error', error: action.error };
default: return state;
}
}
async function loadData() {
dispatch({ type: 'FETCH_START' });
try {
const data = await fetch('/api').then(r => r.json());
dispatch({ type: 'FETCH_SUCCESS', payload: data }); // one atomic update
} catch (err) {
dispatch({ type: 'FETCH_ERROR', error: err });
}
}
Quick Reference
| Bug | Root Cause | Fix |
|---|---|---|
| Stale values in effect | Closed over old state | Functional updater or correct deps |
| Effect runs too often | Unstable object/function in deps | useMemo / useCallback / move into effect |
| Infinite re-render | Object/array in deps recreated each render | useMemo or move to outer scope |
| State change not re-rendering | Direct mutation | Always replace, never mutate |
| Race condition on fetch | No cleanup | AbortController + cleanup fn |
| Hooks order error | Conditional hook call | Always call hooks unconditionally |
→ Debug your component's state by inspecting data structures with the JSON Viewer.