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.

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?

Broken code
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>;
}
Why it breaks
The empty dependency array means the effect runs once on mount. The arrow function inside 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.
Fix: use the functional update form
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.

Broken code
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>;
}
Why it breaks
The empty dependency array means the effect runs only on mount, fetching whatever userId was at that time. When the parent passes a new userId, the effect doesn't rerun.
Fix: list every value the effect reads
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.

Broken code
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>
  );
}
Why it breaks
React decides whether to re-render by comparing old state to new state with 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.
Fix: create a new array
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.

Broken code
function Profile({ first, last }) {
  const [fullName, setFullName] = useState(first + ' ' + last);

  useEffect(() => {
    setFullName(first + ' ' + last);  // sync derived state
  }, [first, last]);

  return <h2>{fullName}</h2>;
}
Why it breaks
This works, but it costs an extra render every time 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.
Fix: compute during render
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.

Broken code
function Search({ enabled, query }) {
  if (enabled) {
    const debounced = useDebounce(query, 300);  // hook inside if!
    return <Results query={debounced} />;
  }
  return null;
}
Why it breaks
React tracks hooks by call order, not by name. If 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.
Fix: call the hook unconditionally, branch inside
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.

Broken code
function Dashboard() {
  const [data, setData] = useState([]);

  useEffect(() => {
    fetchData({ limit: 10, sort: 'asc' }).then(setData);
  }, [{ limit: 10, sort: 'asc' }]);  // new object every render!
}
Why it breaks
The object literal { 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.
Fix: use primitive deps or useMemo
// 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.

Mistake A: useRef where you need re-render
function Likes() {
  const count = useRef(0);

  return (
    <button onClick={() => count.current++}>
      Likes: {count.current}  {/* never updates! */}
    </button>
  );
}
Why it breaks
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.
Mistake B: useState where you need a ref
function Form() {
  const [timer, setTimer] = useState(null);

  const handleSubmit = () => {
    setTimer(setTimeout(saveDraft, 1000));  // wastes a render
  };
  // ...
}
Why it breaks
Storing a timer ID in state causes a re-render every time you set it. The timer ID isn't used in the UI — it's just tracking metadata. Use useRef for that.
The rule
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 Course

Frequently 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.