Back to writing
ReactMay 2026·6 min read

React Bugs That Bite - 7 Classic Mistakes and How to Fix Them

Seven React bugs that appear in real codebases and interviews - stale closures, race conditions, the zero rendering problem, Context re-render traps, and more.

These are not obscure edge cases. They're the bugs that appear in real codebases, during live coding interviews, and in code reviews. Each one has cost someone a frustrating debugging session.

The Stale Closure Trap

This is the most common React bug that senior engineers still make. It's invisible until it bites you.

// Looks fine. Completely broken.
function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    setInterval(() => setSeconds(seconds + 1), 1000);
  }, []);

  return <div>{seconds}</div>; // Shows "1" forever
}

The interval callback closed over seconds at the moment the effect ran - when it was 0. Every tick it reads that same frozen 0 and sets 0 + 1 = 1. The state updates, but always to 1.

Two bugs in four lines: no cleanup (memory leak on unmount) and a stale closure that makes the timer useless.

// Fixed:
useEffect(() => {
  const id = setInterval(() => setSeconds(s => s + 1), 1000);
  return () => clearInterval(id); // cleanup
}, []);
// s => s + 1 reads current state at call time, not closure time

The rule: whenever new state depends on previous state, use the functional update form setState(prev => ...). Don't read state inside timers or subscriptions.


The Race Condition Nobody Notices

User types "a" - request fires. Types "ab" - second request fires. The second response arrives first. Then the first (slower) response arrives and overwrites the correct result. The UI now shows results for "a" while the input says "ab".

// This code has a silent race condition:
useEffect(() => {
  fetch(`/search?q=${query}`)
    .then(r => r.json())
    .then(setResults); // which response wins? whoever arrives last
}, [query]);

// Fixed with AbortController:
useEffect(() => {
  const ctrl = new AbortController();

  fetch(`/search?q=${query}`, { signal: ctrl.signal })
    .then(r => r.json())
    .then(setResults)
    .catch(err => {
      if (err.name !== 'AbortError') throw err; // ignore intentional cancels
    });

  return () => ctrl.abort(); // cancel previous request when query changes
}, [query]);

TanStack Query handles this automatically. If you're writing raw useEffect data fetching in 2026, add the AbortController.


async in useEffect - A Warning That Looks Harmless

// This triggers a React warning and silently breaks cleanup:
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);
// An async function returns a Promise, not a cleanup function.
// React sees a Promise where it expects undefined or a function - ignores it.

The cleanup function never runs. If the component unmounts while the fetch is in flight, setData still fires and you get a state update on an unmounted component.

// Correct pattern:
useEffect(() => {
  let cancelled = false;

  (async () => {
    const data = await fetchData();
    if (!cancelled) setData(data); // check before updating
  })();

  return () => { cancelled = true; }; // real cleanup function
}, []);

The Zero Renders Zero Problem

const items = [];
return <div>{items.length && <List />}</div>;
// Renders: <div>0</div>
// Not: <div></div>

items.length is 0. In JavaScript, 0 && anything evaluates to 0. React renders 0 as a text node. This bug appears in production and confuses users.

// Three ways to fix it:
{items.length > 0 && <List />}     // explicit comparison
{items.length ? <List /> : null}   // ternary
{Boolean(items.length) && <List />} // coerce to boolean

Consistency tip: most teams settle on the ternary for all conditional rendering to avoid this class of bug entirely.


setState With the Same Value - Does It Re-render?

It depends on the reference.

const [obj, setObj] = useState({ a: 1 });

setObj({ a: 1 }); // RE-RENDERS - new object reference, Object.is says "different"
setObj(obj);      // SKIPS render - same reference, Object.is says "equal"

const [count, setCount] = useState(0);
setCount(0); // SKIPS render - same primitive value

React uses Object.is to compare previous and next state. For primitives it compares values. For objects and arrays it compares references. This is why spreading state creates re-renders even when the data hasn't changed - {...state} is always a new reference.


The Context Re-render Trap

// Every render of Provider creates a new object
// Every consumer re-renders, even if they don't use the changed field
<UserCtx.Provider value={{ user, setUser }}>

If user updates 10 times per second and you have 50 components consuming this context, that's 500 re-renders per second from one object literal.

// Fix 1: memoize the value
const value = useMemo(() => ({ user, setUser }), [user]);
<UserCtx.Provider value={value}>

// Fix 2: split by update frequency (better)
<UserCtx.Provider value={user}>         {/* updates sometimes */}
  <SetUserCtx.Provider value={setUser}> {/* never updates */}
    {children}
  </SetUserCtx.Provider>
</UserCtx.Provider>
// Components that only call setUser never re-render when user changes

For frequently-updating state, skip Context entirely. Use Zustand - a store update only re-renders components subscribed to the changed slice.


Index as Key - The Silent Data Corruption Bug

// Works fine until the user deletes or reinserts an item
{items.map((item, i) => <Input key={i} defaultValue={item.name} />)}

Delete the first item. React now maps key 0 to what was previously key 1. The <Input> component thinks it's the same component, just with new props. The internal DOM state (cursor position, typed value) is preserved from the old item. The user's typed text now belongs to the wrong row.

// Always use stable unique IDs:
{items.map(item => <Input key={item.id} defaultValue={item.name} />)}

One legitimate use case for index keys: static, non-reorderable, never-deleted lists. If nothing about the list changes except the contents of each item, index keys are safe.


Keys as a Reset Mechanism

This is the other side of the key story - using it intentionally.

// Problem: editing user A, then switching to user B
// The form keeps user A's unsaved state in local component state
<UserForm userId={userId} />

// Solution: tell React this is a completely different component
<UserForm key={userId} userId={userId} />
// When userId changes, React unmounts the old form and mounts a fresh one
// All internal state resets automatically

This is cleaner than adding a useEffect to reset form state when props change. The key approach is explicit and works with any component without modifying it.