Skip to content
React best practices 5 min read

Common Mistakes & Gotchas

Most React bugs are not exotic—they are the same handful of mistakes repeated across thousands of codebases. They tend to pass code review because the code looks reasonable: a key is present, an effect has a dependency array, state gets updated. The problems show up later as stale UI, lost edits, infinite loops, or mysterious flashes. This page catalogs the classics in a problem-then-fix format so you can recognize and avoid them on sight.

Mutating state directly

React decides whether to re-render by comparing references. If you mutate an object or array in place, the reference does not change, so React assumes nothing happened and skips the update.

// Problem: in-place mutation — React sees the same array reference.
function addTodo(text) {
  todos.push({ id: crypto.randomUUID(), text }); // mutation
  setTodos(todos); // same reference → no re-render
}

// Fix: create a new value every time.
function addTodo(text) {
  setTodos((prev) => [...prev, { id: crypto.randomUUID(), text }]);
}

The same rule applies to objects: spread into a fresh copy (setUser((u) => ({ ...u, name }))) rather than assigning u.name = name. For deeply nested updates, reach for Immer patterns instead of hand-writing spreads.

Always treat state as immutable. The functional updater form (setX(prev => ...)) is the safest default because it never reads a stale variable from an outer closure.

Using the array index as a key

key tells React’s reconciler which list item is which between renders. Using the array index means the key of an item changes the moment you insert, delete, or reorder—so React reuses the wrong DOM nodes and component state lands on the wrong row.

// Problem: index keys break on reorder/insert/delete.
{items.map((item, i) => <Row key={i} item={item} />)}

// Fix: use a stable, unique id from the data.
{items.map((item) => <Row key={item.id} item={item} />)}

The bug is invisible for static lists and brutal for editable ones: type into the third input, delete the first row, and your text jumps. Use a stable id; only fall back to the index for lists that never change order. See key stability for the deeper reasoning.

Missing or over-broad effect dependencies

An effect that omits a value it reads captures a stale closure—it keeps using the value from the render where it was created. The opposite mistake, listing too much, causes effects to re-run constantly.

import { useEffect, useState } from "react";

function Counter({ step }) {
  const [count, setCount] = useState(0);

  // Problem: reads `step` and `count` but lists neither → stale values.
  useEffect(() => {
    const id = setInterval(() => setCount(count + step), 1000);
    return () => clearInterval(id);
  }, []);

  // Fix: drop the stale read with a functional update, depend only on `step`.
  useEffect(() => {
    const id = setInterval(() => setCount((c) => c + step), 1000);
    return () => clearInterval(id);
  }, [step]);

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

Let the eslint-plugin-react-hooks exhaustive-deps rule guide you—never silence it with a comment. If a dependency makes the effect fire too often, the real fix is usually a functional updater, a ref, or moving logic out of the effect entirely.

Fetching in an effect without cleanup

Fetching inside an effect is fine, but without cleanup you get race conditions: a fast second request can resolve before a slow first one, leaving stale data on screen.

import { useEffect, useState } from "react";

function User({ id }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    setUser(null);

    fetch(`https://jsonplaceholder.typicode.com/users/${id}`, {
      signal: controller.signal,
    })
      .then((res) => res.json())
      .then(setUser)
      .catch((err) => {
        if (err.name !== "AbortError") throw err;
      });

    return () => controller.abort(); // cancel on id change / unmount
  }, [id]);

  return <p>{user?.name ?? "Loading…"}</p>;
}

In real apps, prefer a data library such as TanStack Query, which handles caching, deduping, and cancellation for you. See fetching in effects for the full treatment.

Storing derived state in state

If a value can be computed from props or other state, compute it during render. Mirroring it into useState and syncing with an effect doubles your sources of truth and guarantees they drift apart.

// Problem: redundant state synced via effect.
const [fullName, setFullName] = useState("");
useEffect(() => setFullName(`${first} ${last}`), [first, last]);

// Fix: just derive it.
const fullName = `${first} ${last}`;

This applies to filtered lists, totals, validation flags—anything reducible from existing data. See derived state.

The && falsy-value bug

condition && <Component /> renders nothing when the condition is falsy—except 0 and NaN, which React renders as literal text.

// Problem: renders a stray "0" when the cart is empty.
{cart.length && <Badge count={cart.length} />}

// Fix: coerce to a real boolean, or use a ternary.
{cart.length > 0 && <Badge count={cart.length} />}
{cart.length ? <Badge count={cart.length} /> : null}

Any expression left of && that could be 0 is a landmine. Make the left side an explicit boolean.

Over-memoization

useMemo, useCallback, and React.memo are not free—they add comparison and allocation cost, and wrapping everything makes code harder to read while rarely helping.

SymptomWhat it usually means
useCallback on every handlerCargo-culting; most handlers don’t need it
useMemo for a cheap calculationThe memo costs more than the work
React.memo everywhereHiding a state-placement problem

Reach for memoization when you have measured a problem—an expensive computation on every render, or a stable reference required by a memoized child or an effect dependency. Otherwise, write the plain version first.

Premature memoization is the React equivalent of premature optimization. Profile with React DevTools before reaching for it.

Best Practices

  • Treat all state as immutable; update with spreads or functional updaters.
  • Give list items a stable, data-derived key—never the array index for dynamic lists.
  • Trust exhaustive-deps; fix dependency warnings instead of suppressing them.
  • Add cleanup (abort/ignore flags) to every effect that fetches or subscribes.
  • Derive values during render instead of mirroring them into extra state.
  • Guard && with explicit boolean conditions so 0 never leaks into the UI.
  • Memoize only after measuring a real performance problem.
Last updated June 14, 2026
Was this helpful?