RE

Common Mistakes

React · 8 entries

Stale Closures

syntax
// PROBLEM: handler captures old state value
// FIX: use updater function or ref
example
function BrokenCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // BUG: count is always 0 (captured from initial render)
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty deps = closure captures initial count

  return <p>{count}</p>; // stuck at 1
}

function FixedCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // FIX: updater function always has the latest value
      setCount(prev => prev + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <p>{count}</p>; // increments correctly
}

Note Stale closures happen when a callback captures a state value that never updates. Common in setInterval, setTimeout, and event listeners set up in useEffect. Use the updater form of setState or store values in a ref.

Direct State Mutation

syntax
// BAD: mutating state directly
state.items.push(newItem);
setState(state);

// GOOD: create a new reference
setState({ ...state, items: [...state.items, newItem] });
example
function BrokenTags() {
  const [tags, setTags] = useState(['react', 'hooks']);

  function addTag(tag) {
    // BUG: push mutates the existing array
    tags.push(tag);
    setTags(tags); // same reference, React sees no change
  }

  return (
    <div>
      {tags.map(t => <span key={t}>{t}</span>)}
      <button onClick={() => addTag('new')}>Add</button>
    </div>
  );
}

function FixedTags() {
  const [tags, setTags] = useState(['react', 'hooks']);

  function addTag(tag) {
    // CORRECT: spread creates a new array
    setTags(prev => [...prev, tag]);
  }

  return (
    <div>
      {tags.map(t => <span key={t}>{t}</span>)}
      <button onClick={() => addTag('new')}>Add</button>
    </div>
  );
}

Note React uses reference equality (Object.is) to decide whether to re-render. Mutating an object or array in place keeps the same reference, so React skips the update. Always produce a new object/array via spread, map, filter, or slice.

Missing or Bad Keys

syntax
// BAD: index as key with reorderable list
{items.map((item, i) => <Item key={i} />)}

// GOOD: stable unique identifier
{items.map(item => <Item key={item.id} />)}
example
// BUG: using index as key with a removable list
function BrokenList() {
  const [items, setItems] = useState([
    { id: 'a', text: 'First' },
    { id: 'b', text: 'Second' },
    { id: 'c', text: 'Third' },
  ]);

  function removeFirst() {
    setItems(prev => prev.slice(1));
  }

  return (
    <div>
      {/* Using index: after removing first item,
         React thinks items shifted, inputs lose state */}
      {items.map((item, index) => (
        <div key={index}>
          <input defaultValue={item.text} />
        </div>
      ))}
      <button onClick={removeFirst}>Remove First</button>
    </div>
  );
}

// FIX: use item.id as key
// {items.map(item => <div key={item.id}>...)}

Note Index keys cause broken behavior when items are added, removed, or reordered. React associates state (including uncontrolled input values) with the key. When keys shift, state gets attached to the wrong item.

useEffect Dependency Issues

syntax
// MISSING DEPS: effect uses stale values
useEffect(() => { fn(a, b); }, []); // a, b missing

// OBJECT DEPS: infinite loop
useEffect(() => { ... }, [{ x: 1 }]); // new ref each render
example
// BUG: missing dependency
function Greeting({ name }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    // name is used but not in deps -- stale value
    setMessage(`Hello, ${name}!`);
  }, []); // should be [name]

  return <p>{message}</p>;
}

// BUG: object dependency causes infinite loop
function DataLoader({ filters }) {
  useEffect(() => {
    fetchData(filters);
  }, [filters]); // if parent recreates filters object each render, infinite loop!

  // FIX: destructure primitive values
  const { category, minPrice } = filters;
  useEffect(() => {
    fetchData({ category, minPrice });
  }, [category, minPrice]);
}

Note Enable the eslint-plugin-react-hooks exhaustive-deps rule to catch missing dependencies. For object dependencies, extract primitive values or memoize the object in the parent. Never suppress the lint rule without understanding why.

Infinite Re-renders

syntax
// CAUSE 1: setState during render
function Bad() {
  const [x, setX] = useState(0);
  setX(1); // triggers re-render during render!
}

// CAUSE 2: object/array in useEffect deps
useEffect(() => { ... }, [{ a: 1 }]); // new ref every render
example
// BUG: calling setState unconditionally during render
function BrokenComponent({ data }) {
  const [processed, setProcessed] = useState(null);
  // This runs on every render, which triggers a new render...
  setProcessed(transform(data));
  return <Display data={processed} />;
}

// FIX: derive the value without state
function FixedComponent({ data }) {
  const processed = transform(data); // compute inline
  return <Display data={processed} />;
}

// BUG: onClick={handler()} calls immediately
function BrokenButton() {
  const [count, setCount] = useState(0);
  // handler() executes during render, calling setCount, looping
  return <button onClick={setCount(count + 1)}>Click</button>;
}

// FIX: pass function reference
function FixedButton() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>Click</button>;
}

Note Infinite re-render loops crash your app. The most common causes: (1) calling setState directly in the render body, (2) onClick={fn()} instead of onClick={fn}, (3) useEffect with an object/array literal in the dependency array that creates a new reference every render.

Overusing useEffect

syntax
// ANTI-PATTERN: effect to sync derived state
useEffect(() => { setB(computeFrom(a)); }, [a]);

// BETTER: compute inline
const b = computeFrom(a);
example
// BAD: useEffect chain
function PriceCalculator({ items }) {
  const [subtotal, setSubtotal] = useState(0);
  const [tax, setTax] = useState(0);
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setSubtotal(items.reduce((s, i) => s + i.price, 0));
  }, [items]);

  useEffect(() => {
    setTax(subtotal * 0.1);
  }, [subtotal]);

  useEffect(() => {
    setTotal(subtotal + tax);
  }, [subtotal, tax]);
  // 3 extra renders for what should be 0!

  return <p>Total: ${total.toFixed(2)}</p>;
}

// GOOD: calculate everything inline
function PriceCalculator({ items }) {
  const subtotal = items.reduce((s, i) => s + i.price, 0);
  const tax = subtotal * 0.1;
  const total = subtotal + tax;

  return <p>Total: ${total.toFixed(2)}</p>;
}

Note Each unnecessary useEffect + setState causes an extra render cycle. If a value can be derived from props or state, compute it during render. Reserve useEffect for genuine side effects: API calls, subscriptions, DOM manipulation, and third-party library integration.

Setting State After Unmount

syntax
// PROBLEM: async callback sets state on unmounted component
useEffect(() => {
  fetchData().then(data => setState(data)); // may be unmounted!
}, []);

// FIX: track mounted status
useEffect(() => {
  let active = true;
  fetchData().then(data => { if (active) setState(data); });
  return () => { active = false; };
}, []);
example
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function loadUser() {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();

      // Only update state if this effect is still active
      if (!cancelled) {
        setUser(data);
      }
    }

    loadUser();

    return () => {
      cancelled = true;
    };
  }, [userId]);

  if (!user) return <p>Loading...</p>;
  return <h1>{user.name}</h1>;
}

Note While React 18+ no longer warns about this, it is still a logic bug. Without the cancelled flag, a slow request for user A could resolve after switching to user B, overwriting user B's data with user A's data. Always use a cleanup flag or AbortController.

State Batching Behavior

syntax
// React 18+ batches ALL state updates automatically
function handleClick() {
  setA(1);  // does not trigger render
  setB(2);  // does not trigger render
  setC(3);  // single render with all three updates
}
example
function StatusPanel() {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  async function loadData() {
    setLoading(true);
    setError(null);
    // React batches these two -- one render

    try {
      const result = await fetchDashboardData();
      setData(result);
      setLoading(false);
      // React batches these two -- one render
    } catch (err) {
      setError(err.message);
      setLoading(false);
      // React batches these two -- one render
    }
  }

  return (
    <div>
      {loading && <p>Loading...</p>}
      {error && <p>Error: {error}</p>}
      {data && <Dashboard data={data} />}
    </div>
  );
}

Note Since React 18, automatic batching applies to all updates, including those in promises, setTimeout, and native event handlers. You rarely need to worry about batching anymore. If you ever need to force a synchronous render, use flushSync (imported from react-dom) as a last resort.