React Hooks, Honestly
useState snapshots, useEffect timing and cleanup, stale closures, refs, and an honest, measured look at useMemo and useCallback — with live React demos of every bug and its fix.
The 30-second version
Four bugs that every React developer meets sooner or later: a like button that goes up by 1 when you click it twice; a timer that always logs 0; a profile page that shows the wrong person after you switch quickly; and a codebase with useMemo wrapped around everything "for performance". They all have the same root cause, and it fits in one sentence.
First, the words. A hook is a function whose name starts with use (useState, useEffect, …) that a component calls to get React features. To render means React calls your component function to find out what the screen should show. React renders again every time state changes.
This works because of closures: a JavaScript function remembers the variables that existed where it was created. A click handler written during photo #3 remembers photo #3's count. Try it:
The timer reported the value from the photo it was armed in, not the value on screen. Nothing is broken: that's how JavaScript closures always behave. What makes React feel surprising is that the same line of code runs once per photo, so there are many count constants over time, one per render, and each function holds on to one of them.
State is a snapshot
Effects sync the outside world
Memoizing is a trade
useMemo costs a little on every render to save a lot on some. Measure first (§10).The rules of hooks, and why they exist
When you write const [likes, setLikes] = useState(0), nothing tells React the word likes. The string "likes" never reaches React. What React sees is "the component called useState for the 3rd time". So React stores each component's state as a numbered list of slots, and hands them out in order on every render.
Step through what happens when one useState hides inside an if:
1function Profile({ showBio }) {2 const [name, setName] = useState("Ana");3 if (showBio) {4 const [bio, setBio] = useState("Hi!");5 }6 const [likes, setLikes] = useState(0);7 return <p>{name}: {likes} likes</p>;8}
showBio = true. React keeps a list of slots for each component on the page. The list is empty; this component has never rendered before.From that one design choice come the two rules of hooks:
- Call hooks only at the top level of your component: not inside
if, loops, nested functions, or after an earlyreturn. That keeps the call order identical on every render. - Call hooks only from React components or from your own hooks (functions whose name starts with
use; §11). Plain helper functions and event handlers don't have a slot list.
import { useRef, useState } from "react";
function Profile({ showBio }) {
const [name] = useState("Ana");
if (showBio) {
const [bio] = useState("Hi!"); // ✗ sometimes call #2…
}
const [likes] = useState(0); // …sometimes call #2 too
if (!name) return null;
const ref = useRef(null); // ✗ skipped after the early return
}function Profile({ showBio }) {
const [name] = useState("Ana");
const [bio] = useState("Hi!"); // ✓ always call #2
const [likes] = useState(0); // ✓ always call #3
const ref = useRef(null); // ✓ before any return
if (!name) return null;
// decide what to SHOW with the condition, not which hooks to call:
return <p>{name}{showBio && <i>{bio}</i>}: {likes}</p>;
}Let a tool enforce it
You don't need to remember this; a Linter can. The official eslint-plugin-react-hooks (version 7.1 when we tested it) reports the conditional call before you ever run the code. This is its real output on the bad example above, both mistakes found:
5:19 error React Hook "useState" is called conditionally. React Hooks must be called
in the exact same order in every component render react-hooks/rules-of-hooks
9:15 error React Hook "useRef" is called conditionally. React Hooks must be called in
the exact same order in every component render. Did you accidentally call
a React Hook after an early return? react-hooks/rules-of-hooks(Line 5 is the useState inside the if, and line 9 is the useRef after the early return, counting from the import line.)
Install it in every React project. The same plugin also checks dependency arrays (§05) and catches several of the other bugs on this page.
useState: a snapshot, not a variable
setCount(5) doesn't change count. It asks React for a new render in which count will be 5. The count you're holding stays what it was.Inside one render, count is a const. It really is one: const [count, setCount] = useState(0) declares a brand-new constant every time the component function runs. React fills it with the current value from its slot (§02). Nothing can change it until the function runs again.
So what does setCount do? It drops a note in React's queue: "next time, slot 1 should be 5". Then React schedules a new render. When React calls your component again, the new const count gets 5.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // ← still the old value! This render's photo hasn't changed
}
return <button onClick={handleClick}>{count}</button>;
}Click once and the console prints 0, while the screen shows 1. That's the render snapshot: handleClick belongs to the render where count was 0.
The bug: two clicks, one like
Now make the handler wait for something, like a server, before setting state. Two quick clicks both start in the same photo, because the first click hasn't changed likes yet. Both read likes = 0, and both end up asking for "make it 1":
The fix is the updater function: instead of a value, pass a function. React calls it with the latest queued value, not the value in your photo.
async function like() {
await saveLike(); // takes 0.8 s
setLikes(likes + 1); // likes is from the photo
} // where the click happenedasync function like() {
await saveLike();
setLikes((l) => l + 1); // React passes the newest value
} // as l, whenever this runsThe same thing happens without any await. The +3 in one click button above calls the setter three times in one handler. With setLikes(likes + 1), all three calls read the same photo, so the queue holds "make it 1" three times. With the updater, the queue holds "add 1" three times, and React works through them: 0 → 1 → 2 → 3. React handles all three in one render; that grouping is called batching. Here's what ends up in React's queue in each case:
1// in this render, likes is 02function plusThree() {3 setLikes(likes + 1);4 setLikes(likes + 1);5 setLikes(likes + 1);6}
likes is 0. React's update queue for likes is empty.Objects, arrays, lazy state and useReducer
When you call a setter, React compares the new value with the old one using Object.is, which is basically ===. For numbers and strings that compares values. For objects and arrays it compares identity: are these the very same object in memory? (This rule is called same value.) If they're the same, React assumes nothing changed and skips the render.
So if you change an array in place and hand React the same array back, React sees "same object" and does nothing. Try it:
- ☐ buy milk
- ☐ walk dog
Try: press the left button twice (nothing happens on screen, but memory grows), then the right one once.
The left button changed the array, but React didn't re-render, so the screen went out of date. Worse, the hidden items appear later, the next time anything re-renders: a bug that shows up far away from its cause. Treat state as Immutable: never change it, always replace it.
// arrays
setTodos([...todos, newTodo]); // add
setTodos(todos.filter((t) => t.id !== id)); // remove
setTodos(todos.map((t) => t.id === id ? { ...t, done: true } : t)); // update one
// objects
setUser({ ...user, name: "Bo" }); // change one field
setUser((u) => ({ ...u, address: { ...u.address, city: "Hanoi" } })); // nested: copy each levelThe spread copy is a shallow copy: nested objects are shared, not copied. That's why the nested example copies address too. If you're writing three levels of spreads, the state is probably shaped wrong; flatten it, or use a reducer (below).
Lazy initial state
The value you pass to useState is only used on the first render, but the expression is evaluated on every render, because it's ordinary code inside your component:
const [draft, setDraft] = useState(
JSON.parse(localStorage.getItem("draft")) // runs every render,
); // result thrown awayconst [draft, setDraft] = useState(
() => JSON.parse(localStorage.getItem("draft")) // React calls it
); // only oncePassing a function is called lazy initial state. Use it when the starting value is expensive to compute; for useState(0) or useState("") there's nothing to save.
useReducer: when updates follow rules
Here's a data-loading component written with three useStates:
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
// Nothing stops loading=true AND error="…" at the same time.
// Every handler must remember to set all three correctly.With useReducer, all the rules live in one function. Components only say what happened:
function reducer(state, action) {
switch (action.type) {
case "start": return { status: "loading", data: null, error: null };
case "success": return { status: "done", data: action.data, error: null };
case "failure": return { status: "error", data: null, error: action.error };
default: throw new Error(`unknown action ${action.type}`);
}
}
function Users() {
const [state, dispatch] = useReducer(reducer, { status: "idle", data: null, error: null });
// in a handler or effect:
// dispatch({ type: "start" });
// dispatch({ type: "success", data });
}Reach for useReducer when the next state depends on the previous one in more than one way, when several fields must change together, or when you'd like to Unit test the rules with no React at all: a reducer is just a function. For one independent value, useState is simpler.
useEffect: synchronizing with the outside world
Rendering must be a calculation with no side effects: React may render a component several times, or start a render and throw it away. So "open a connection" can't happen during render. An effect is React's place for it: code that runs after React has applied the render to the page. That applying step is called the commit.
function ChatRoom({ roomId }) {
useEffect(() => {
const conn = connect(roomId); // setup: start syncing
return () => conn.disconnect(); // cleanup: stop syncing
}, [roomId]); // re-sync when roomId changes
return <h2>Welcome to {roomId}!</h2>;
}useEffect takes two arguments: a setup function, and a dependency array. The setup may return a cleanup function that undoes it. Here's the full life of that effect, step by step:
1function ChatRoom({ roomId }) {2 useEffect(() => {3 const conn = connect(roomId); // setup4 console.log("✅ connect", roomId);5 return () => { // cleanup6 conn.disconnect();7 console.log("❌ disconnect", roomId);8 };9 }, [roomId]); // re-sync when roomId changes10 return <h2>Welcome to {roomId}!</h2>;11}
<ChatRoom roomId="general"> appears for the first time. React calls the function (render) and gets back an <h2>. The effect function was created during this render, but not run: effects never run during render.The dependency array, precisely
| You write | The effect runs after… |
|---|---|
useEffect(fn) | every commit |
useEffect(fn, []) | the first commit only (mount) |
useEffect(fn, [a, b]) | mount, then any commit where a or b changed (by Object.is) |
The array is not a menu where you choose when the effect runs. It's a promise to React: "these are all the values from the component that my effect reads". If the effect reads roomId and you leave it out, the effect keeps using the roomId from the first render: a stale value, like the timer in §01. The linter's react-hooks/exhaustive-deps rule checks this promise for you.
Try it: a live effect
The panel below runs a real useEffect, connected to a pretend chat server. Switch rooms, hide the room, then switch the toggle to forgot cleanup and do the same:
Without a cleanup, every room switch opens a new connection and never closes the old one. In a real app that means duplicate messages, a growing memory bill and a server holding sockets nobody uses. Every setup that starts something needs a cleanup that stops it: subscribe/unsubscribe, setInterval/clearInterval, addEventListener/removeEventListener, start a request/abort it (§07).
What effects are not for
The most common effect bug isn't a missing cleanup; it's an effect that shouldn't exist. Two cases:
1. Computing something from props or state. Just compute it during render. It's a derived value:
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(first + " " + last);
}, [first, last]);
// renders once with the OLD fullName, then againconst fullName = first + " " + last;
// always up to date, one render, no state to syncThe left version renders twice per change, and for one render the screen shows the old name. The linter now flags it. This is eslint-plugin-react-hooks 7.1's real message for that code:
error Error: Calling setState synchronously within an effect can trigger cascading renders
…
Avoid calling setState() directly within an effect react-hooks/set-state-in-effect2. Reacting to a user action. If code should run because the user clicked, put it in the click handler. An effect runs because a render happened, and it doesn't know why. "Send the order" inside an effect that watches submitted will also fire on remount, or twice in StrictMode.
When effects run: useEffect vs useLayoutEffect, measured
useLayoutEffect runs right after React changes the DOM and before the browser paints, every time. useEffect runs after that, and you should not rely on it running before or after the paint.To show anything, the browser has to paint: turn the updated DOM into pixels. It does that in frames, normally about 60 per second. (The browser rendering topic covers the pipeline.)
The browser runs JavaScript in tasks: one click handler, one timer callback, each runs from start to finish without interruption (the Event loop is what picks the next task; Async / Await §07 shows it step by step). The browser can only paint between tasks. So the question "does my effect run before or after the user sees the change?" really means "does my effect run in the same task as the commit, or a later one?".
Press both buttons a few times. The timestamps are real, taken in your browser with performance.now():
Here's what we measured in a production build of React 19.3 on headless Chromium (on this machine), 20 runs of each:
| Update caused by | What happened | useEffect ran after | next frame began after |
|---|---|---|---|
| a click | 20 / 20: render → useLayoutEffect → useEffect, all in one task; then the frame | 0.1–1.4 ms | 1.2–13.9 ms |
| a timer | 15 / 20: useEffect in its own task, before the next frame 5 / 20: the frame (and paint) came first, then useEffect | 0.2–2.3 ms | 0.7–10.3 ms |
Two things to notice:
- After a click (or a key press), React runs
useEffectimmediately, in the same task as the commit. React 18 made this the rule for these discrete input events, so effect timing after a click is predictable. The paint comes later. - For other updates (timers, network responses),
useEffectruns in a separate task, and whether the browser paints first depends on whether a frame happens to be due. In our runs it went one way 15 times and the other way 5 times. Switch the widget to slow one (30 ms): the layout effect now takes so long that a frame is overdue when it ends, and the browser paints before running anything else. In our runs, the paint came first 12 times out of 12.
So the honest rule is: useEffect is "soon after the commit", nothing more precise. Usually that doesn't matter. It matters in exactly one situation.
The one job for useLayoutEffect: measure, then fix before anyone sees
A tooltip must sit just above its button, but you can only know the button's position after it's in the DOM. If you measure in useEffect, the browser may paint one frame with the tooltip in the wrong place, then move it: a visible flicker. useLayoutEffect guarantees the fix lands before the first paint:
function Tooltip({ anchorRef, children }) {
const tipRef = useRef(null);
const [top, setTop] = useState(0);
useLayoutEffect(() => {
const anchor = anchorRef.current.getBoundingClientRect(); // measure the real DOM
const tip = tipRef.current.getBoundingClientRect();
setTop(anchor.top - tip.height - 8); // ← re-renders BEFORE the browser paints
}, [anchorRef]); // a ref never changes, so this measures once, on mount;
// add deps (e.g. children) to re-measure when the content changes
return <div ref={tipRef} style={{ position: "fixed", top }}>{children}</div>;
}getBoundingClientRect() returns an element's position and size on screen. useRef and the ref={…} prop give you the DOM node itself; §08 explains them properly.
Fetching in effects: the race you didn't see
Here's the profile page everyone writes first:
function Profile({ userId }) {
const [profile, setProfile] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setProfile(data)); // ← whoever answers LAST wins
}, [userId]);
// …
}Responses don't come back in the order you asked for them. If Alice's server takes 1.5 s and Bob's takes 0.3 s, clicking Alice and then Bob gives you this:
setProfile too, so the page now shows Alice while Bob is selected.That's a Race condition: the result depends on which request happens to finish last. Try all three versions:
Fix 1: an ignore flag
Every render's effect has its own variables (it's a Closure over that render). So a let ignore = false inside the effect belongs to that request only. The cleanup runs when userId changes, and flips it:
useEffect(() => {
let ignore = false; // one flag per effect run
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!ignore) setProfile(data); // ← old request? drop the result
});
return () => { ignore = true; }; // runs when userId changes or on unmount
}, [userId]);When you click Bob, React runs Alice's cleanup (setting Alice's ignore to true) before starting Bob's effect. Alice's response still arrives, about a second after Bob's, but it's thrown away.
Fix 2: actually cancel with AbortController
The ignore flag hides the old result, but the browser still downloads it. AbortController cancels the request itself:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal }) // ← hand fetch the signal
.then((res) => res.json())
.then((data) => setProfile(data))
.catch((err) => {
if (err.name === "AbortError") return; // cancelled on purpose: not an error
setError(err); // another useState setter
});
return () => controller.abort(); // ← cleanup cancels the request
}, [userId]);Aborting also stops a response body that's still downloading: res.json() rejects with the same AbortError. Keep the ignore flag for work in the effect that a signal can't stop, like a setTimeout or a library call that doesn't accept a signal. Abort works with await too: inside an async function, the await fetch(…) line throws the AbortError.
useRef: a box that survives renders
useRef gives you a box, { current: … }, that React keeps for the component's whole life. You can change what's in the box at any time, and React doesn't notice: no re-render.Back to the photo booth. State values are printed in the photo. A ref is different: it's a box sitting next to the booth. Every photo (render) gets a pointer to the same box, so a function made in an old render that reads ref.current sees what's in the box now, not what was there when its photo was taken.
count is printed into its photo and never changes. All renders share one ref object, so a function from photo #1 that reads countRef.current sees what's in the box now: 2.const countRef = useRef(0); // the same object on every render
countRef.current += 1; // change the contents: allowed, silentThe two halves of that sentence, side by side:
useState | useRef | |
|---|---|---|
| Changing it | setX(v) | ref.current = v |
| Causes a re-render? | yes | no |
| Value inside one render | fixed snapshot | whatever is in the box right now |
| Use it for | anything shown on screen | things the screen doesn't depend on |
What refs are for
1. DOM nodes. Pass a ref as the ref prop and React puts the real DOM element in .current after the commit. That's how you call browser methods React doesn't wrap: focus(), scrollIntoView(), getBoundingClientRect(), play() on a video.
function SearchBox() {
const inputRef = useRef(null); // null until React attaches the <input>
return (
<>
<input ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>Search</button>
</>
);
}2. Values you need later but never show: a timer id so you can clear it, the previous value of a prop, a flag like "is a request already running?".
const timerRef = useRef(null);
function start() { timerRef.current = setInterval(tick, 1000); }
function stop() { clearInterval(timerRef.current); }3. "The latest value" for long-lived callbacks. A timer created in the first render can read the newest state through a ref. That's one of the fixes for the stale interval in the next section.
Stale closures: the interval that always sees 0
Most functions you create during render are short-lived: a click handler from render #5 is replaced by the one from render #6. The trouble starts when something keeps calling an old function: a setInterval, a subscription, a WebSocket listener, set up once and never replaced.
function Ticker() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count from render #1: always 0
}, 1000);
return () => clearInterval(id);
}, []); // [] = set up once, never rebuild
return <p>{count}</p>;
}Walk through it with real numbers. Render #1: count is 0. The effect runs once (empty array) and starts an interval whose function was written on photo #1. Tick 1: it calls setCount(0 + 1), so the screen shows 1. Tick 2: the same function runs again, still reading photo #1, where count is 0. It calls setCount(0 + 1) again. The screen stays at 1 forever.
Run it, then try each fix:
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count is from the FIRST render: always 0
}, 1000);
return () => clearInterval(id);
}, []); // ← "never re-run": the interval is never rebuiltThe four fixes, and when to use each
1. Tell the truth in the dependency array: [count]. The effect now re-runs whenever count changes: cleanup clears the old interval, setup starts a new one that reads the new photo. It works, and the linter's exhaustive-deps rule suggests it. The log shows the cost: the interval is torn down and rebuilt on every tick. For an interval that's harmless; for a WebSocket connection it's a reconnect per message.
2. Don't read the value at all: the updater function. setCount(c => c + 1) (from §03) doesn't need count from the photo. React passes the latest value. The closure is still stale, but it no longer matters. This is the best fix whenever the only thing you do with the old value is compute the next state. The linter itself suggests it:
warning React Hook useEffect has a missing dependency: 'count'. Either include it or remove
the dependency array. You can also do a functional update 'setCount(c => ...)' if you
only need 'count' in the 'setCount' call react-hooks/exhaustive-deps3. Read the latest value from a ref. Copy the value into a ref after each render; the long-lived function reads the box instead of the photo (§08):
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }); // no array: after every render
useEffect(() => {
const id = setInterval(() => {
setCount(countRef.current + 1); // ← the box always holds the newest count
}, 1000);
return () => clearInterval(id);
}, []);It works for anything, not just setters, but it's manual and easy to get wrong (forget the first effect and the box goes stale too).
4. useEffectEvent: the official version of the ref trick. Wrap the "read the newest values" part in an effect event. It always sees the latest props and state, and it is deliberately not a dependency, so the effect doesn't re-run:
import { useEffect, useEffectEvent, useState } from "react";
function Ticker({ step }) {
const [count, setCount] = useState(0);
const onTick = useEffectEvent(() => {
setCount(count + step); // ← latest count AND latest step
});
useEffect(() => {
const id = setInterval(() => onTick(), 1000);
return () => clearInterval(id);
}, []); // the interval is created once
return <p>{count}</p>;
}useEffectEvent became stable in React 19.2. This site runs 19.3.1, where it's exported from "react", and the widget's last mode uses it for real. Two rules: only call the function it returns from inside effects (not during render, not as an onClick), and don't put it in a dependency array.
useMemo and useCallback, honestly
useMemo and useCallback remember a value between renders. They pay off in two cases: a calculation that's genuinely slow, and keeping a value's identity stable for a memoized child or a dependency array. Everywhere else they're noise.Remembering a result so you don't recompute it is called memoization. The hooks:
const visible = useMemo(() => filterTodos(todos, tab), [todos, tab]);
// ↑ calculation ↑ recompute only when these change
const onPick = useCallback((id) => setSelected(id), []);
// same as: useMemo(() => (id) => setSelected(id), [])On every render, React compares the dependencies with last time (Object.is, like effects). Same → return the saved value without calling your function. Different → call it and save the new result. useCallback(fn, deps) is just useMemo for a function: it hands back the same function object until the deps change.
What it costs
Nothing is free. Every render, useMemo still creates your arrow function (to pass it in), allocates the deps array, and compares each dependency. That's tiny, but so is most of what people wrap in it. We measured a cheap calculation (a + b) in 100 components re-rendered 1,000 times, so 100,000 component renders, in a production build:
| 100,000 renders of… | Total time (4 runs) |
|---|---|
const v = a + b | 25–38 ms |
const v = useMemo(() => a + b, [a, b]) | 30–35 ms |
Measured on this machine (headless Chromium). That's about 0.3 microseconds per component render either way, and the runs overlap: the difference is lost in the noise, which is the point: for cheap work useMemo saves nothing and adds lines to read and dependencies to keep correct.
Case 1: a calculation that's actually slow
Here's a calculation that is slow: filtering and sorting 30,000 products. Type in the note field (which only re-renders the component) with and without useMemo, and watch the measured time:
What we measured in a production build (this machine, headless Chromium; medians of 19 keystrokes, over three rounds; 23,604 products match the default search "e"):
| Typing in the note field (re-render, same search) | Changing the search | |
|---|---|---|
no useMemo | ≈10–13 ms per keystroke | ≈11 ms |
useMemo | 0.00 ms (cached) | ≈11 ms |
Ten milliseconds per keystroke is most of the Frame budget of a 60 Hz screen, spent recomputing an answer that didn't change. useMemo doesn't make the calculation faster; changing the search costs the same either way. It skips the calculation on renders where the inputs didn't change. That matters when the calculation is slow and the component re-renders often for other reasons, like typing. The React docs suggest roughly 1 ms as the point where it's worth considering. Measure with console.time or the React Profiler; don't guess.
Case 2: keeping identity stable for a memo child
memo(Component) lets a child skip re-rendering when its parent re-renders, if every prop is the same value as last time. Functions and objects created during render are new every time, so passing onPick={(id) => …} defeats it. Toggle through the three modes:
memo compares props with Object.is. onPick is a brand-new function on every parent render, so the props never look equal and memo skips nothing.
The lesson is the pairing: memo without stable props skips nothing, and useCallback without a memo child (or an effect that depends on it) does nothing useful: the function is stable, but nobody is checking. The same applies to objects passed as props or used in a dependency array (§05): useMemo(() => ({ room, url }), [room, url]) keeps an options object from re-triggering an effect every render.
The React Compiler
The React Compiler (babel-plugin-react-compiler, version 1.0 on npm) rewrites your components at build time so they cache values automatically. We ran it on a small component that filters a list and passes a handler to a child. Abridged, this is what it produced:
import { c as _c } from "react/compiler-runtime";
function TodoList(t0) {
const $ = _c(10); // 10 cache slots for this component
const { todos, filter, onToggle } = t0;
let t1;
if ($[0] !== filter || $[1] !== todos) { // inputs changed? recompute
t1 = todos.filter((t) => t.status === filter);
$[0] = filter; $[1] = todos; $[2] = t1;
} else {
t1 = $[2]; // otherwise reuse the cached array
}
// …the same pattern caches handleClick and the returned JSX
}That's useMemo, useCallback and memo written for you, at a finer grain than people write by hand. Two honest caveats from the same experiment: it only works if your code follows the rules of hooks and React. When we fed it a component with a conditional useState, it skipped that component and left it as written, reporting "Hooks must always be called in a consistent order". And it's a build step you opt into; this site doesn't use it. If your project turns the compiler on, follow the React docs' advice: don't add new useMemo/useCallback by default, and leave existing ones in place unless you test their removal carefully. They can still matter, for example when a memoized value feeds an effect's dependency array.
Custom hooks: extract, share, test
use and that calls other hooks. It shares logic, not state: every component that calls it gets its own state.There's no magic in a custom hook. React doesn't even know it exists: when SearchBox calls useDebounce, which calls useState and useEffect, React just sees SearchBox making those hook calls, in order, like always (§02). The use prefix is a promise to humans and to the linter that the rules of hooks apply inside.
useDebounce
To debounce a value means: only pass it on once it has stopped changing for a while. Typing "barcelona" changes the text nine times; a debounced value changes once, 300 ms after the last key. Here it is, built from an effect with a cleanup (§05):
import { useEffect, useState } from "react";
export function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // value changed again? cancel the old timer
}, [value, delay]);
return debounced;
}Every keystroke changes value, so React runs the cleanup (cancelling the pending timer) and starts a new one. Only when you pause for 300 ms does a timer survive long enough to fire.
useFetch, with the race fixed
The fetch from §07, packaged so no component has to remember the abort:
import { useEffect, useState } from "react";
export function useFetch(url) {
const [result, setResult] = useState({ url: null, data: null, error: null });
useEffect(() => {
if (!url) return; // nothing to fetch
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setResult({ url, data, error: null }))
.catch((error) => {
if (error.name !== "AbortError") setResult({ url, data: null, error });
});
return () => controller.abort(); // url changed or unmounted: cancel
}, [url]);
const current = result.url === url; // ← is the result we hold for THIS url?
return {
data: current ? result.data : null,
error: current ? result.error : null,
loading: Boolean(url) && !current, // derived, not stored (§05)
};
}Notice loading isn't state. The result remembers which url it belongs to, and loading is simply "we're asked for a url we don't have a result for yet". That avoids calling setLoading(true) at the top of the effect, which the linter's set-state-in-effect rule would flag, and it can never get out of sync.
Using both takes two lines; the rest is rendering:
function CitySearch() {
const [text, setText] = useState("");
const term = useDebounce(text.trim(), 300);
const { data, loading, error } = useFetch(term ? `/api/cities?q=${encodeURIComponent(term)}` : null);
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
{loading && <p>Loading…</p>}
{error && <p>Something went wrong: {error.message}</p>}
{data?.map((city) => <p key={city}>{city}</p>)}
</>
);
}Here's the same pair running against a pretend server that takes 0.45 s per search. Type "barcelona" in both modes and compare the counters:
Without debouncing, nearly every keystroke starts a request and aborts the one before (which is the abort doing its job). With useDebounce, typing at normal speed sends one request.
Testing a hook
Because a custom hook is plain logic, you can test it without building a page. renderHook from React Testing Library renders a tiny component that calls your hook and gives you its latest return value. With fake timers from Vitest, you control the clock, so the test takes milliseconds instead of waiting 300 ms. You need three dev packages: npm i -D vitest @testing-library/react jsdom. jsdom is a fake browser page (document, window) that runs inside Node; React needs it to render anything in a test.
// @vitest-environment jsdom
import { renderHook, act } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { useDebounce } from "./useDebounce.js";
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
test("only the last value survives a burst of changes", () => {
const { result, rerender } = renderHook(({ v }) => useDebounce(v, 300), {
initialProps: { v: "r" },
});
rerender({ v: "re" });
rerender({ v: "rea" });
expect(result.current).toBe("r"); // nothing yet: timer still pending
act(() => vi.advanceTimersByTime(300)); // let 300 ms of fake time pass
expect(result.current).toBe("rea"); // the intermediate "re" never appeared
});Running npx vitest run with Vitest 5.0.5, @testing-library/react 16 and jsdom 29 printed this (real output):
RUN v5.0.5 ~/my-app
✓ useDebounce.test.js > only the last value survives a burst of changes 14ms
Test Files 1 passed (1)
Tests 1 passed (1)Without the first-line comment, the same test fails with ReferenceError: document is not defined, because Vitest runs tests in plain Node by default. Instead of the comment in every file, you can set test: { environment: "jsdom" } once in vitest.config.js.
Unit Testing covers component tests and test structure in general, and Mocks, Stubs & Fakes covers fakes; this is the React-specific part.
Staff level: fewer effects, real stores, transitions
useEffects are either replaced by something more specific (a calculation, an event handler, a data library, useSyncExternalStore) or wrapped in a small custom hook. A raw effect in a component is worth a second look in code review.1. Treat effects as a smell, not a tool
Go through the effects in a real codebase and sort them. In our experience most fall into these buckets, and only the last one is a genuine effect:
| The effect does… | Replace it with |
|---|---|
setX(f(props, state)) | compute it during render (§05); useMemo if it's slow (§10) |
| reset state when a prop changes | give the component a key: <Profile key={userId} /> makes React start it fresh |
| run code after a click / submit | the event handler itself |
| fetch data | a data library or your framework's loader (below) |
| subscribe to a store / browser API | useSyncExternalStore (below) |
| connect to a widget, socket, or timer while on screen | ✅ a real effect, ideally inside a custom hook (§11) |
The key trick works because React treats a component with a new key as a different component: it unmounts the old one (dropping its state) and mounts a fresh one. How React Works explains keys in depth.
Each effect you remove deletes a render (no "set state, render again"), a dependency array that can go stale, and an ordering question ("which effect runs first?").
2. Server state is a different problem
Data from your API isn't really your state; it's a cached copy of the server's. That copy needs caching, deduplication of identical requests, refetching when it goes stale, retries, and invalidation after you change something. That's a lot to build on useEffect. This kind of data is called server state, and dedicated libraries exist for it:
import { useQuery } from "@tanstack/react-query";
function Profile({ userId }) {
const { data, isPending, error } = useQuery({
queryKey: ["user", userId], // the cache key; changes → new query
queryFn: ({ signal }) => // the library passes an AbortSignal
fetch(`/api/users/${userId}`, { signal }).then((r) => r.json()),
});
// …
}The race from §07 is handled (results are stored per queryKey, so a late Alice response can't land in Bob's slot), and going back to Alice is instant from cache. State Management compares the options; the point here is that useEffect is the low-level primitive these libraries are built on, not the API you should reach for first.
3. useSyncExternalStore: subscribing correctly
When state lives outside React (a module variable, localStorage, navigator.onLine, a store from another library), you need React to re-render when it changes. That's an external store. The tempting version is an effect plus useState. The correct one is useSyncExternalStore, and this site uses it. Here's the real code behind the Go/JavaScript preference on code tabs (src/lib/store.js, trimmed):
import { useSyncExternalStore } from "react";
const subs = new Set(); // everyone who wants to hear about changes
let lang = readSavedLang(); // the current value (read from localStorage)
export function setLang(next) {
lang = next;
subs.forEach((fn) => fn()); // tell React: "something changed, re-read me"
}
export function useLang() {
return useSyncExternalStore(
(fn) => (subs.add(fn), () => subs.delete(fn)), // subscribe; return unsubscribe
() => lang, // read the current value
() => null // value during server rendering
);
}You give React three functions: how to subscribe, how to read the current value (the snapshot), and what to use when rendering on a server. React handles the rest: subscribing once, unsubscribing on unmount, and making sure every component sees the same value in the same render, even when React pauses a render halfway. An effect-based version can't promise that: if the value changes mid-render, some components may show the old value and some the new one, a glitch called tearing. One rule: the read function must return the same value (===) if nothing changed. Returning a fresh object each time (() => ({ lang })) makes React re-render forever. This site's src/lib/progress.js follows the rule by replacing its state object only when progress actually changes, and it also listens to the browser's storage event so a second tab stays in sync.
4. Transitions: urgent vs not urgent
Some updates must feel instant (the letter you just typed). Others can lag a little (the big list filtered by it). React lets you mark the second kind as a transition. React then renders it at low priority, in slices, and drops a half-finished render if a newer update arrives. That ability to interrupt a render is concurrent rendering. Two hooks expose it:
useDeferredValue(value)returns a copy ofvaluethat's allowed to lag behind. Render the slow part with the copy.useTransition()gives youstartTransition(() => setX(…))to mark a specific update as non-urgent, plusisPendingto show a spinner.
The list below takes about 50 ms to render. Type quickly in both modes:
What we measured in a production build (this machine, headless Chromium, typing 10 letters 30 ms apart):
| Mode | Keystroke → next frame |
|---|---|
| plain | average 77–97 ms (two rounds): every keystroke waits for the whole list |
useDeferredValue | average 12–16 ms: about one frame; the list catches up afterwards |
const SlowList = memo(function SlowList({ text }) { /* … */ }); // ← memo is required
function Search() {
const [text, setText] = useState("");
const deferredText = useDeferredValue(text); // may lag behind `text`
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<SlowList text={deferredText} />
</>
);
}The memo is not optional. On each keystroke, React first renders with the old deferredText; memo is what lets SlowList skip that render, so the input updates fast. Then React renders the list with the new text in the background. Transitions don't make the slow work faster; they make sure it doesn't block the urgent work. If the list is slow because it renders 10,000 rows, the real fix is still to render fewer (pagination or virtualization).
Check yourself: predict what happens
Commit to an answer before you click. Each explanation points back to the section that covers it. Most of these come straight from real code reviews.
const [n, setN] = useState(0);
function onClick() {
setN(n + 1);
setN(n + 1);
setN(n + 1);
}
// n is 0. The user clicks once. What does the screen show?const [name, setName] = useState("Ana");
function onClick() {
setName("Bo");
console.log(name);
}useEffect(() => {
document.title = `${count} new messages`;
});const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
// What does the screen show after 5 seconds?A profile page fetches /users/{id} in an effect. Switching quickly from a slow user to a fast one sometimes shows the slow user. Which change fixes it?
const Row = memo(function Row({ style }) { /* … */ });
function List() {
const [n, setN] = useState(0);
return <Row style={{ color: "red" }} />;
}
// List re-renders because n changed. Does Row re-render?Cheat sheet
setX asks for the next photo; it doesn't edit this one.if, loops or after an early return. Let eslint-plugin-react-hooks enforce it.setX(x => x + 1) whenever the next value depends on the previous one.[...a, x], { ...o, k: v }); React compares by identity.memo child / dependency. Otherwise skip it.| Hook | Use it for | Watch out for |
|---|---|---|
useState | values the screen shows | stale reads after setX (§03); mutation (§04) |
useReducer | state with rules, several fields that change together | keep the reducer pure |
useEffect | syncing with something outside React while on screen | missing cleanup (§05); races (§07) |
useLayoutEffect | measuring the DOM before paint | blocks the paint (§06) |
useRef | DOM nodes, ids, latest values | reading it during render (§08) |
useMemo / useCallback | slow calculations; stable identity | cost on every render; pointless without a reader (§10) |
useEffectEvent | reading latest values from inside an effect (React 19.2+) | only call it from effects (§09) |
useSyncExternalStore | subscribing to state outside React | snapshot must be === stable (§12) |
useDeferredValue / useTransition | keeping input fast next to slow rendering | needs memo on the slow part (§12) |
Symptom → cause → fix
| You see… | Probably… | Fix |
|---|---|---|
| Two quick clicks add 1, not 2 | setter reads the snapshot | setX(x => x + 1) |
console.log after setX prints the old value | the snapshot (that's normal) | use the new value you computed, or read it next render |
| Screen doesn't update after a change | mutated an array/object in state | replace it with a copy |
| A timer or listener always sees the first value | stale closure | updater, deps, ref, or useEffectEvent |
| Duplicate events / connections, memory growing | missing cleanup | return a cleanup that undoes the setup |
| Wrong user's data after switching fast | request race | AbortController or ignore flag in the cleanup |
| An effect runs on every render | object/function in the deps | depend on primitives, or memoize the object |
| Everything runs twice in development | StrictMode checking your cleanups | fix the cleanup; production runs once |
| UI flickers into place | measured the DOM in useEffect | useLayoutEffect |
memo child still re-renders | a new function/object prop each render | useCallback / useMemo for that prop |