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.
Most of the mistakes in this guide start with one of these assumptions:
A new variable means a new value.
For objects and arrays, a new variable can still point to the same value in memory.
More state makes data easier to manage.
Duplicating information creates multiple sources of truth that can disagree.
An effect is a general place to calculate values.
Effects are for synchronizing React with something external, not for ordinary render calculations.
The browser and React will remain synchronized automatically.
A form element can keep its own DOM state unless React controls it.
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.
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.
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
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."
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<inputvalue={query}onChange={handleChange}/>// DOM controls it after the first render<inputdefaultValue="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.
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:
"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 valuesreturn()=>{// undo that setup};},[dependency]);
React follows this sequence:
text
mount: setup
dependency change: old cleanup → new setup
unmount: final cleanup
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.
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:
calling the API for every keystroke;
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
"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
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.
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.
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:
TypeScript interfaces do not exist at runtime, so they cannot be used with instanceof.
Reusable custom type guard
ts
functionisAbortError(error:unknown): error is Error {return error instanceofError&& error.name ==="AbortError";}try{awaitloadProducts();}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.
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:
Name the source of truth.
Say what is derived.
Describe the failure mode.
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.