Most React Hooks tutorials teach you the syntax. They show you that useState returns an array, that useEffect runs after render, that useRef holds a value. None of them prepare you for the bugs you'll actually hit in production.
This post walks through seven real mistakes every React developer makes — stale closures, infinite loops, mutation bugs, the Rules of Hooks violations. Each one comes with broken code, an explanation of why it breaks, and the fix.
If you're already comfortable with the basic syntax, this is the post that turns "I can write hooks" into "I understand hooks". If you're new, bookmark this for the day a bug stares back at you.
What you'll learn
Quick Answer
Most React Hook bugs come from one of three causes: stale closures (functions remembering old state), reference instability (new objects on every render breaking effects and memoization), or mutation (changing state without telling React). Master these three and 90% of hook bugs disappear.
Mistake 1: Stale Closure in useEffect
You set up an interval to log the count every second. The count updates on screen. But the console keeps logging 0. What's happening?
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // always logs 0
}, 1000);
return () => clearInterval(id);
}, []); // empty deps - effect runs once
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
setInterval captures count from that render, where it was 0. Even when the component re-renders with new count values, the interval keeps using its captured copy. This is a stale closure.
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // gets fresh value from React
}, 1000);
return () => clearInterval(id);
}, []);
Or, if you actually need to read the latest count inside the effect (not just update it), include it in dependencies — but be aware the interval will be torn down and recreated each second.
Mistake 2: Missing Dependency = Stale Data or Infinite Loop
You fetch user data when the user ID changes. But it only fetches the first user — switching to another user shows nothing.
function UserCard({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser);
}, []); // missing userId in deps
return user ? <p>{user.name}</p> : <p>Loading...</p>;
}
userId was at that time. When the parent passes a new userId, the effect doesn't rerun.
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser);
}, [userId]); // re-run when userId changes
The opposite mistake — listing too many dependencies — causes infinite loops (covered in Mistake 6). The ESLint rule react-hooks/exhaustive-deps catches both. Keep it on.
Mistake 3: Mutating State Directly
You add an item to a list. The state "changes" but the UI doesn't update.
function TodoList() {
const [todos, setTodos] = useState(['Learn React']);
const addTodo = (text) => {
todos.push(text); // mutates the same array
setTodos(todos); // React sees the same reference, skips re-render
};
return (
<ul>{todos.map(t => <li key={t}>{t}</li>)}</ul>
);
}
Object.is. todos.push() changes the array in place, so the reference is the same. setTodos(todos) hands back the same reference. React thinks nothing changed and skips the render.
const addTodo = (text) => {
setTodos([...todos, text]); // new array, new reference
};
// Or with the functional form:
const addTodo = (text) => {
setTodos(prev => [...prev, text]);
};
Same rule for objects: setUser({ ...user, name: 'New' }), never user.name = 'New'; setUser(user).
Mistake 4: Using State for Derived Values
You store the user's full name as state, recalculating it whenever first or last name changes. Bugs follow.
function Profile({ first, last }) {
const [fullName, setFullName] = useState(first + ' ' + last);
useEffect(() => {
setFullName(first + ' ' + last); // sync derived state
}, [first, last]);
return <h2>{fullName}</h2>;
}
first or last changes (one for the prop change, another after the effect updates state). It also creates a brief moment where fullName is stale. Any value that can be calculated from existing props or state should not be its own state.
function Profile({ first, last }) {
const fullName = first + ' ' + last; // just compute it
return <h2>{fullName}</h2>;
}
If the computation is expensive, wrap it in useMemo. But for cheap calculations (string concatenation, simple math, basic filtering), just compute during render. Less state means fewer bugs.
Mistake 5: Calling Hooks Conditionally
You only need a debounced value when search is enabled. So you only call the hook when needed... and React explodes.
function Search({ enabled, query }) {
if (enabled) {
const debounced = useDebounce(query, 300); // hook inside if!
return <Results query={debounced} />;
}
return null;
}
enabled goes from true to false, the hook count changes between renders. React's internal state gets out of sync, leading to errors like "Rendered fewer hooks than expected" or — worse — silent state corruption where one hook reads another's data.
function Search({ enabled, query }) {
const debounced = useDebounce(query, 300); // always call
if (!enabled) return null;
return <Results query={debounced} />;
}
The Rules of Hooks: always call hooks at the top level of your function, in the same order, on every render. The condition goes after the hook, not around it.
Mistake 6: Object/Function in Dependency Array (Infinite Loop)
You pass an options object to a custom hook. Suddenly your effect runs in a loop, your network tab fills with requests, and your laptop fan kicks on.
function Dashboard() {
const [data, setData] = useState([]);
useEffect(() => {
fetchData({ limit: 10, sort: 'asc' }).then(setData);
}, [{ limit: 10, sort: 'asc' }]); // new object every render!
}
{ limit: 10, sort: 'asc' } is a new object on every render. React compares dependencies with Object.is, which compares references for objects — so it always sees a different value, runs the effect, which causes a re-render, which creates another new object, which runs the effect again. Infinite loop.
// Option A: list primitives instead
useEffect(() => {
fetchData({ limit, sort }).then(setData);
}, [limit, sort]);
// Option B: memoize the object once
const opts = useMemo(() => ({ limit: 10, sort: 'asc' }), []);
useEffect(() => {
fetchData(opts).then(setData);
}, [opts]);
Same problem with functions: a new function reference on every render breaks useEffect, useCallback, and useMemo. Wrap functions you pass as dependencies in useCallback.
Mistake 7: Using useRef When You Need useState (or Vice Versa)
You want to track a value across renders. Should it be useState or useRef? Confusing the two creates UI that doesn't update or unnecessary re-renders.
function Likes() {
const count = useRef(0);
return (
<button onClick={() => count.current++}>
Likes: {count.current} {/* never updates! */}
</button>
);
}
useRef mutations don't trigger re-renders. The value updates internally but React never re-runs the component, so the UI freezes at 0. useRef is for values you want to remember, not display.
function Form() {
const [timer, setTimer] = useState(null);
const handleSubmit = () => {
setTimer(setTimeout(saveDraft, 1000)); // wastes a render
};
// ...
}
useRef for that.
useState → the value is shown in the UI (re-render needed)
useRef → the value persists but the UI doesn't depend on it
Examples of useRef: DOM element references, timer IDs, previous values, mutable counters that aren't displayed, scroll positions you only check inside event handlers.
Master React fundamentals
Our React course covers hooks, state management, and real-world patterns hands-on.
Start React CourseFrequently Asked Questions
Why does my useEffect run in an infinite loop?
An object or function in the dependency array gets a new reference on every render, which triggers the effect, which causes a re-render, repeating forever. Fix it by either removing the dependency, using primitive values instead, or wrapping the value in useMemo/useCallback to keep its reference stable.
What is a stale closure in React Hooks?
A stale closure happens when a function (like a setTimeout callback) captures state from an old render, so when it runs later, it uses outdated values. Fix it with the functional update form: setCount(c => c + 1) instead of setCount(count + 1), or by including the captured value in the dependency array.
Why is my React state not updating immediately?
useState updates are asynchronous and batched. Calling setCount(count + 1) doesn't change count until the next render. If you need to update based on the latest value, use the functional form: setCount(c => c + 1). To use the new value in the same function, store it in a local variable first.
When should I use useRef vs useState?
Use useState when changes should trigger a re-render (UI depends on the value). Use useRef when you need to remember a value across renders without triggering a re-render — like a DOM reference, a timer ID, or any tracking value the UI does not display.
Can I call React Hooks inside if statements?
No. Hooks must be called in the same order on every render. React tracks them by call order, not by name. Calling a hook conditionally would change the order between renders and break React's internal state. If you need conditional behavior, put the condition inside the hook, not around it.
Related Reading
The Bottom Line
React Hooks are a tiny API but a deep mental model. The bugs above all come from forgetting one of three things: functions can capture stale state, references matter for dependencies, and React doesn't re-render unless you give it a new reference.
Once these click, hooks stop feeling magical and start feeling boring — in the best way. You'll predict bugs before you write them, read others' code faster, and stop reaching for useEffect when a plain calculation will do.
The official React docs at react.dev are the best deep reference. The bugs in this post are what most people learn the hard way. Now you've got a head start.
