Skip to content
anila.

React mistakes that are easy to miss

author avatar of anila website

React code can look fine and still hide a bug. A mutated array might render correctly once. An uncontrolled form can stay in sync until someone adds a reset button. Requests may arrive in order on your machine, then race on a slower network.

This guide looks at why those bugs happen and how to reason about them, include wrong and corrected examples, the behavior you can observe, and a checklist for tracking down similar bugs.

Examples use React with TypeScript. Most principles also apply to JavaScript.

Contents

  1. Don't store what you can calculate
  2. Copy before you sort: arrays are references
  3. Let React own your form values
  4. Pass an initializer instead of calling it on every render
  5. Every effect setup needs a cleanup plan
  6. Debouncing does not cancel a request
  7. Use keydown for shortcuts and respect event bubbling
  8. Treat caught errors as unknown
  9. An empty array is not a UI state
  10. Troubleshooting common React bugs
  11. Check your understanding

Why these mistakes are easy to miss

Most of the mistakes in this guide start with one of these assumptions:

  1. A new variable means a new value.
    For objects and arrays, a new variable can still point to the same value in memory.
  2. More state makes data easier to manage.
    Duplicating information creates multiple sources of truth that can disagree.
  3. An effect is a general place to calculate values.
    Effects are for synchronizing React with something external, not for ordinary render calculations.
  4. The browser and React will remain synchronized automatically.
    A form element can keep its own DOM state unless React controls it.
  5. Asynchronous work belongs to the render that started it.
    Timers, requests, and event listeners can outlive that render and must be cancelled or cleaned up.

1. Don't store what you can calculate

Keep only the facts the UI needs in state. If existing props or state tell you everything needed to produce a value, calculate it during render.

Suppose a product screen already stores:

  • products: products received from the API
  • favoriteIds: IDs selected by the user
  • showFavoritesOnly: whether filtering is enabled

The visible list is the result of those three facts, not a fourth fact to store.

Wrong: synchronize derived state with an effect

tsx
const [products, setProducts] = useState<Product[]>([]);
const [favoriteIds, setFavoriteIds] = useState<number[]>([]);
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const [visibleProducts, setVisibleProducts] = useState<Product[]>([]);

useEffect(() => {
  const next = showFavoritesOnly
    ? products.filter((product) => favoriteIds.includes(product.id))
    : products;

  setVisibleProducts(next);
}, [products, favoriteIds, showFavoritesOnly]);

This creates two phases:

text
state changes → render with old visibleProducts
              → effect runs
              → setVisibleProducts
              → second render with new visibleProducts

Now there is another piece of state that can drift out of sync.

Correct: calculate during render

tsx
const visibleProducts = showFavoritesOnly
  ? products.filter((product) => favoriteIds.includes(product.id))
  : products;

If the calculation is measurably expensive, useMemo can avoid repeating it:

tsx
const visibleProducts = useMemo(
  () =>
    showFavoritesOnly
      ? products.filter((product) => favoriteIds.includes(product.id))
      : products,
  [products, favoriteIds, showFavoritesOnly],
);

useMemo is not what makes this design correct. The important part is deriving the list instead of duplicating it in state. Memoization is a performance tool, and it can also preserve a reference when a consumer depends on reference stability.

React 19 is not a reason to wrap every calculation in useMemo. React Compiler can memoize many calculations automatically when it is enabled. Without the compiler, a small filter is usually clearer without manual memoization and cheap enough to run again.

Why the wrong version seems to work

The effect often runs soon enough to hide the intermediate render. The weakness shows up when:

  • one dependency is accidentally omitted;
  • API data changes but the copied state is not updated;
  • multiple updates happen close together;
  • the extra render causes flicker or unnecessary work.

2. Copy before you sort: arrays are references

An array variable holds a reference to an array in memory, not the array itself.

ts
const products = [{ id: 1 }, { id: 2 }];
const visible = products;

Memory model:

text
products ──┐
           ├──► one array in memory
visible  ──┘

Changing the array through either variable changes the same array.

Wrong: sort state through another variable

tsx
const visible = products;
visible.sort((a, b) => a.price - b.price);

const visible gives the same array another name. It does not create an array. Because sort() changes its receiver, it reorders products too.

Correct: copy first, then sort

tsx
const visible = [...products];
visible.sort((a, b) => a.price - b.price);

Or use the non-mutating toSorted() method when supported by your target environment:

const visible = products.toSorted((a, b) => a.price - b.price);

After copying:

text
products ──► original array
visible  ──► new array containing the same product references

The spread makes a shallow copy. The outer array is new, but the product objects inside it are still shared:

tsx
const copy = [...products];
copy[0].title = "Changed"; // also changes products[0].title

To update one object safely:

tsx
setProducts((previous) =>
  previous.map((product) =>
    product.id === targetId
      ? { ...product, title: "Changed" }
      : product,
  ),
);

Wrong: mutate an array and return the same reference

tsx
setFavoriteIds((previous) => {
  previous.push(id);
  return previous;
});

Correct: return a new array

tsx
setFavoriteIds((previous) =>
  previous.includes(id)
    ? previous.filter((favoriteId) => favoriteId !== id)
    : [...previous, id],
);

The functional updater works from the latest committed state. That matters when React batches several updates.

Common mutating array methods

These modify the original array:

ts
push();
pop();
shift();
unshift();
splice();
sort();
reverse();
fill();
copyWithin();

These do not mutate the original array:

ts
map();
filter();
slice();
concat();
flat();
flatMap();
toSorted();
toReversed();
toSpliced();
with();

Why mutation seems to work

Another state update may happen to trigger a render, so the mutated contents still appear on screen. React was never told that this array changed, though. The bug tends to surface later when memoization, batching, undo logic, or a "default order" feature relies on the original reference.

3. Let React own your form values

Form controls can keep their current value in the DOM. React state can hold the same value. If each manages it independently, they can disagree.

Wrong: update React but let the DOM own the display

tsx
const [category, setCategory] = useState("all");

<select onChange={(event) => setCategory(event.target.value)}>
  <option value="all">All</option>
  <option value="books">Books</option>
</select>

<button onClick={() => setCategory("all")}>Reset</button>

When the user picks an option, the browser updates the select and the handler updates React state. Everything appears to match. Clicking Reset is different: it changes only React state, so the uncontrolled select may still show "Books."

Correct: state controls the displayed value

tsx
const [category, setCategory] = useState("all");

<select
  value={category}
  onChange={(event) => setCategory(event.target.value)}
>
  <option value="all">All</option>
  <option value="books">Books</option>
</select>

<button onClick={() => setCategory("all")}>Reset</button>

Data flow:

text
user action ──► onChange ──► setCategory
list filter ◄── category state ──► select value

onChange writes to state; value feeds that state back into the control.

The same principle uses different props for different form controls:

tsx
<input
  type="text"
  value={query}
  onChange={(event) => setQuery(event.target.value)}
/>

<input
  type="checkbox"
  checked={showFavoritesOnly}
  onChange={(event) => setShowFavoritesOnly(event.target.checked)}
/>

Do not use value for a checkbox’s boolean state. Use checked.

value versus defaultValue

value controls the element on every render. defaultValue only provides its initial DOM value:

tsx
// React controls it on every render
<input value={query} onChange={handleChange} />

// DOM controls it after the first render
<input defaultValue="initial text" ref={inputRef} />

Uncontrolled inputs are perfectly valid when React does not need their value during render. A simple form that is read once on submit is one example. They are a poor fit when the value filters a list, drives validation, or needs a programmatic reset.

Why an uncontrolled select seems to work

If the initial state matches the first option and only the user changes it, DOM state and React state happen to move together. They part ways after:

  • a reset button changes state;
  • saved filters are restored;
  • options are loaded asynchronously;
  • a parent changes the selected value.

4. Pass an initializer instead of calling it on every render

useState accepts either an initial value or an initializer function.

tsx
useState(initialValue);
useState(() => createInitialValue());

When you pass an initializer function, React calls it while initializing the component and stores the returned value.

Wrong: execute the setup while evaluating every render

const [favoriteIds, setFavoriteIds] = useState<number[]>(loadFavorites());

JavaScript evaluates loadFavorites() before useState runs. That call happens on every render, even though React ignores the result after initialization.

Correct: pass the initializer function

tsx
const [favoriteIds, setFavoriteIds] = useState<number[]>(loadFavorites);

// Equivalent and sometimes more explicit:
const [favoriteIds, setFavoriteIds] = useState<number[]>(
  () => loadFavorites(),
);

Persist structured data with JSON

localStorage stores strings. JSON preserves the array structure and number types:

tsx
const FAVORITES_KEY = "favorite_ids";

function loadFavorites(): number[] {
  try {
    return JSON.parse(localStorage.getItem(FAVORITES_KEY) ?? "[]");
  } catch {
    return [];
  }
}

function ProductList() {
  const [favoriteIds, setFavoriteIds] =
    useState<number[]>(loadFavorites);

  useEffect(() => {
    localStorage.setItem(
      FAVORITES_KEY,
      JSON.stringify(favoriteIds),
    );
  }, [favoriteIds]);

  // ...
}

Wrong: use toString() as a storage format

ts
const ids = [1, 5, 12];

ids.toString();       // "1,5,12"
JSON.stringify(ids);  // "[1,5,12]"

"1,5,12" is not valid JSON. If you split it yourself, you get strings rather than numbers:

["12"].includes(12); // false

The try/catch keeps corrupted storage, an old format, or a manually edited value from breaking startup.

Development note: In Strict Mode, React may call an initializer twice during development to expose accidental side effects. It ignores one result. Keep initializers pure apart from safe reads such as loading storage. Do not write, subscribe, or mutate external data in them.

Server rendering needs a separate path because window and localStorage do not exist on the server. Read browser storage only in client code, or provide an initial value that is safe on the server.

Why the wrong version seems to work

React still keeps only the first result, so the UI looks right. You may not notice the repeated JSON parsing or expensive setup until the component renders often.

5. Every effect setup needs a cleanup plan

Use an effect when a component needs to synchronize with something outside React:

  • a network connection;
  • a browser event listener;
  • a timer;
  • a third-party widget;
  • browser storage.

Filtering an array is a render calculation, not external synchronization. It usually does not belong in an effect.

Effect lifecycle

For an effect with dependencies:

tsx
useEffect(() => {
  // setup using the current values

  return () => {
    // undo that setup
  };
}, [dependency]);

React follows this sequence:

text
mount:             setup
dependency change: old cleanup → new setup
unmount:           final cleanup

Wrong: add a global listener without removing it

tsx
useEffect(() => {
  window.addEventListener("keydown", handleKeyDown);
}, []);

Correct: undo the exact subscription

tsx
useEffect(() => {
  function handleKeyDown(event: KeyboardEvent) {
    if (event.key === "Escape") closeDialog();
  }

  window.addEventListener("keydown", handleKeyDown);

  return () => {
    window.removeEventListener("keydown", handleKeyDown);
  };
}, [closeDialog]);

removeEventListener must receive the same function reference used by addEventListener.

This does not work:

tsx
window.addEventListener("keydown", () => closeDialog());
window.removeEventListener("keydown", () => closeDialog());
// The two arrow functions are different objects.

Correct: cancel a fetch on cleanup

tsx
useEffect(() => {
  const controller = new AbortController();

  fetch("/api/products", { signal: controller.signal })
    .then((response) => response.json())
    .then(setProducts)
    .catch((error: unknown) => {
      if (error instanceof Error && error.name === "AbortError") return;
      setStatus("error");
    });

  return () => controller.abort();
}, []);

Dependencies are values used by the effect

List the reactive values that the effect uses. Leave one out, and the effect may keep seeing an old value through its closure.

Do not silence the hooks linter by reflex. Check whether the effect is needed, whether a function can move inside it, or whether a value genuinely needs a stable reference.

Why missing cleanup seems to work

A short test may create the listener or timer only once. In development, Strict Mode deliberately runs an extra setup and cleanup cycle, which exposes effects that cannot restart safely. Without cleanup, longer sessions can accumulate duplicate handlers, stale values, and wasted work.

6. Debouncing does not cancel a request

A search box presents two different timing problems:

  1. calling the API for every keystroke;
  2. allowing an old, slow response to overwrite a newer result.

Debouncing reduces how often you start a request. Cancelling or ignoring stale requests keeps an older response from replacing newer data.

setTimeout does not pause JavaScript

ts
console.log("A");
setTimeout(() => console.log("C"), 300);
console.log("B");

// A
// B
// C (at least 300ms later)

setTimeout schedules one callback to run after at least the given delay. JavaScript keeps going immediately.

setInterval repeatedly schedules a callback until clearInterval is called:

ts
const id = setInterval(refreshClock, 1000);
clearInterval(id);

Use setInterval for clocks or periodic polling, not for "wait until typing stops."

Wrong: delay loading state but start the request immediately

tsx
useEffect(() => {
  const timer = setTimeout(() => {
    setStatus("loading");
  }, 300);

  searchProducts(query).then(setProducts); // Runs immediately

  return () => clearTimeout(timer);
}, [query]);

Correct: schedule the request and cancel the schedule

tsx
useEffect(() => {
  const timer = setTimeout(() => {
    searchProducts(query).then(setProducts);
  }, 300);

  return () => clearTimeout(timer);
}, [query]);

Typing timeline:

text
"a"   → schedule T1
"ap"  → cancel T1, schedule T2
"app" → cancel T2, schedule T3
quiet for 300ms → T3 runs once

That is debouncing. Each new value cancels the pending callback and schedules another.

Debounce cannot stop an HTTP request that already started

Suppose the user pauses after "a", so request A starts. They then type "apple", and request B starts later:

text
request A: "a"     ─────────────────────► returns last
request B: "apple"       ───────► returns first

B sets correct products
A later overwrites them with stale products

This is a race condition. Starting request A before request B says nothing about which one will finish first.

Example: debounce and abort before the next request

tsx
const controllerRef = useRef<AbortController | null>(null);

useEffect(() => {
  const timer = setTimeout(async () => {
    controllerRef.current?.abort();

    const controller = new AbortController();
    controllerRef.current = controller;

    try {
      setStatus("loading");

      const trimmedQuery = query.trim();
      const products = trimmedQuery
        ? await searchProducts(trimmedQuery, controller.signal)
        : await fetchProducts(20, controller.signal);

      setProducts(products);
      setStatus("success");
    } catch (error: unknown) {
      if (error instanceof Error && error.name === "AbortError") return;
      setStatus("error");
    }
  }, 300);

  return () => clearTimeout(timer);
}, [query]);

The ref keeps the controller across renders without triggering another render. The controller does not describe the UI, so it should not be state.

This version aborts the previous request when the next debounced request begins. There is still a short window after query changes and before the new timer fires in which the old request can finish. If stale data must never update the UI, create a controller for each effect run and abort it in cleanup, or guard updates with a request ID.

Responsibilities:

text
clearTimeout(timer)       cancels work that has not started
controller.abort()        cancels a fetch already in flight
controller.signal         connects the controller to fetch
controllerRef.current     remembers the current request across renders

Creating the controller inside the effect and aborting it from cleanup closes that window. Whichever structure you choose, pass a signal to every request and make sure an outdated response cannot update the UI.

Why the bug seems to work

On a fast, stable network, responses often return in order. Add latency, rapid typing, server load, or a mobile connection, and the race becomes much easier to hit.

7. Use keydown for shortcuts and respect event bubbling

Use keydown for keyboard shortcuts

For modern browser code:

  • keydown: fires when a key is pressed; suitable for Escape, arrows, and shortcuts;
  • keyup: fires when the key is released;
  • keypress: deprecated and unreliable for non-printable keys such as Escape.

Wrong: use deprecated keypress for Escape

window.addEventListener("keypress", handleKey);

Correct: listen for keydown and clean it up

tsx
useEffect(() => {
  if (!selectedProduct) return;

  function handleKeyDown(event: KeyboardEvent) {
    if (event.key === "Escape") {
      setSelectedProduct(null);
    }
  }

  window.addEventListener("keydown", handleKeyDown);
  return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedProduct]);

Listening on window makes the shortcut global to the page, no matter which child has focus. If the behavior belongs to one focused element, put React's onKeyDown on that element instead.

Understand event bubbling

Most events begin at the target and bubble through ancestors:

button → modal → overlay → document → window

This can make a modal close when the user clicks inside it.

Common pattern: backdrop closes, content does not

tsx
<div
  className="overlay"
  onClick={() => setSelectedProduct(null)}
>
  <div
    className="modal"
    role="dialog"
    aria-modal="true"
    aria-label={selectedProduct.title}
    onClick={(event) => event.stopPropagation()}
  >
    Modal content
  </div>
</div>

stopPropagation() prevents the inner click from reaching the overlay.

An alternative avoids stopping propagation by checking the original target:

tsx
<div
  className="overlay"
  onClick={(event) => {
    if (event.target === event.currentTarget) {
      setSelectedProduct(null);
    }
  }}
>
  <div className="modal">Modal content</div>
</div>
  • target: the deepest element that was clicked;
  • currentTarget: the element whose handler is currently running.

The target check is useful when nested components still need clicks to bubble for analytics or another parent handler.

Accessibility is more than ARIA

role="dialog" and aria-modal="true" describe the element to assistive technology, but they do not make the interaction complete. A production modal should also:

  • move focus into the dialog when it opens;
  • keep keyboard focus inside while open;
  • restore focus to the trigger when it closes;
  • provide an accessible name;
  • prevent inappropriate background interaction.

Why the bug seems to work

Testing only with a mouse and the close button misses a lot. Keyboard use exposes broken Escape handling and focus behavior. A backdrop handler can also seem correct until the modal contains something clickable.

8. Treat caught errors as unknown

TypeScript treats a caught value as unknown because JavaScript can throw anything:

ts
throw new Error("Failed");
throw "Failed";
throw { reason: "Failed" };
throw null;

That makes this unsafe:

Wrong: access a property on unknown

tsx
try {
  await loadProducts();
} catch (error) {
  if (error.name === "AbortError") return;
}

TypeScript reports:

'error' is of type 'unknown'.

Wrong: silence the compiler with an assertion

if ((error as Error).name === "AbortError") return;

as Error performs no runtime check. It only asks TypeScript to trust the claim.

Correct: narrow at runtime

tsx
try {
  await loadProducts();
} catch (error: unknown) {
  if (error instanceof Error && error.name === "AbortError") return;

  console.error(error);
  setStatus("error");
}

Once error instanceof Error succeeds, TypeScript narrows the value from unknown to Error.

typeof and instanceof answer different questions

typeof is useful for primitive categories:

ts
typeof "hello";          // "string"
typeof 42;               // "number"
typeof undefined;        // "undefined"
typeof new Error("x");   // "object"
typeof [];               // "object"
typeof null;             // "object" (historical JavaScript behavior)

instanceof checks whether a constructor's prototype appears in an object's prototype chain:

ts
new Error("x") instanceof Error; // true
new Date() instanceof Date;      // true
[] instanceof Array;             // true

Use specialized checks where appropriate:

ts
Array.isArray(value);
value === null;
Number.isNaN(value);

TypeScript interfaces do not exist at runtime, so they cannot be used with instanceof.

Reusable custom type guard

ts
function isAbortError(error: unknown): error is Error {
  return error instanceof Error && error.name === "AbortError";
}

try {
  await loadProducts();
} catch (error: unknown) {
  if (isAbortError(error)) return;
  setStatus("error");
}

Why assertion seems to work

Many browser APIs reject with Error objects, so the assertion often happens to be right. It fails as soon as a library, test double, or application throws a string, plain object, or null.

9. An empty array is not a UI state

An empty array does not tell you what happened:

  • data has not loaded yet;
  • loading succeeded but returned no items;
  • loading failed;
  • a filter produced no matches.

Wrong: infer every state from array length

tsx
const [products, setProducts] = useState<Product[]>([]);

return products.length === 0
  ? <p>No products</p>
  : <ProductGrid products={products} />;

The first render says "No products" before the request has finished. A failed request may show the same message.

Correct: model request status explicitly

tsx
type Status = "loading" | "error" | "success";

const [products, setProducts] = useState<Product[]>([]);
const [status, setStatus] = useState<Status>("loading");

if (status === "loading") {
  return <p role="status">Loading products…</p>;
}

if (status === "error") {
  return (
    <div role="alert">
      <p>Could not load products.</p>
      <button onClick={loadProducts}>Try again</button>
    </div>
  );
}

if (products.length === 0) {
  return <p>No matching products.</p>;
}

return <ProductGrid products={products} />;

The branches now form a small state machine:

text
loading ──success──► success with data
   ├──success──► success with empty result
   └──failure──► error ──retry──► loading

For more complex screens, a discriminated union prevents impossible combinations:

ts
type ProductsState =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "success"; products: Product[] };

Now "loading" cannot accidentally carry a stale error message, and TypeScript knows which fields belong to each branch.

Accessibility basics commonly missed

  • Use real <button> elements for actions, not clickable <div> elements.
  • Give icon-only buttons an accessible name with aria-label.
  • Use aria-pressed for toggle buttons such as a favorite star.
  • Use role="status" for polite loading updates and role="alert" for important errors.
  • Give form controls visible labels or an appropriate accessible name.
  • Use stable data IDs as React keys; avoid array indexes when items can move, be inserted, or be removed.

Why the wrong version seems to work

A fast local request may hide the incorrect empty state in a brief flash. Slow devices, failed networks, and real empty results make the ambiguity obvious.

10. Troubleshooting common React bugs

When a React screen behaves strangely, work through these questions.

State and calculation

  • Is this state a real independent fact, or can existing state calculate it?
  • Is the same information stored in two places?
  • Am I using an effect for a calculation that belongs during render?
  • Is useMemo solving measured work or reference stability, or is it being used automatically?

References and updates

  • Did I call sort, push, splice, or another mutating method on state?
  • Did I create a new variable but forget to create a new array or object?
  • Is my spread copy shallow, and am I mutating a nested object?
  • Should this update use the functional form: setState(previous => ...)?

Forms

  • Does React need this input value to render other UI?
  • If yes, does the control have both value/checked and onChange?
  • Can a reset or parent update change state without updating the displayed control?

Effects

  • What external system is this effect synchronizing with?
  • What must be undone when dependencies change or the component unmounts?
  • Does cleanup receive the same listener, timer ID, subscription, or controller?
  • Are all reactive values used by the effect represented correctly?

Asynchronous work

  • Can an older request finish after a newer one?
  • Does every cancellable fetch receive an AbortSignal?
  • Is AbortError ignored instead of shown as a real failure?
  • Am I cancelling only a timer when the HTTP request has already started?

UI and accessibility

  • Are loading, error, empty, and success distinct?
  • Can the feature be used with a keyboard?
  • Are controls correctly named?
  • Are list keys stable IDs?

11. Check your understanding

Try explaining the concept without looking back at the examples. A useful order is:

  1. Name the source of truth.
  2. Say what is derived.
  3. Describe the failure mode.
  4. Explain what prevents it.

Learning example:

The API products and favorite IDs are the sources of truth. The visible list is derived during render, so I do not duplicate it in state. When sorting, I copy the array first because sort mutates its receiver. The select is controlled so programmatic resets and the displayed option cannot drift apart.

For asynchronous search, try to explain both responsibilities separately:

I debounce the request with a timeout and cancel the timeout in the effect cleanup. That prevents a request for every keystroke. Debouncing cannot stop a request that already started, so I also pass an AbortSignal and abort the previous request to prevent stale results from winning a race.

A working rule

Keep one source of truth. Treat render as calculation and effects as synchronization. Then write asynchronous code as if it will finish in the least convenient order, because eventually it will.