Skip to content
Software Atlas

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.

⏱ ~55 min read14 sections16 interactive widgetsJSX · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

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.

In one sentenceEach render is a photograph. Every function created during a render (click handlers, timers, effects) sees the values in that photo, forever, even after newer photos are taken.

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:

Each render is a photograph
Arm a 3-second timer, then click +1 a few times before it fires. What will the timer report?
The film strip: one photo per render that changed count1 so far
photo #1
count = 0
on screen
on screen: 0
Try: press ⏱, then +1 three times quickly, then wait.

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.

1

State is a snapshot

Inside one render, state never changes. Setting it schedules the next photo (§03).
2

Effects sync the outside world

They run after React updates the page, and must clean up after themselves (§05; exact timing in §06).
3

Memoizing is a trade

useMemo costs a little on every render to save a lot on some. Measure first (§10).
Foundations§02

The rules of hooks, and why they exist

In one sentenceReact doesn't know your variable names. It finds each piece of state by call order: the 1st hook call gets slot 1, the 2nd gets slot 2. So every render must make the same calls in the same order.

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:

Why hooks can't go inside an if
React matches hook calls to stored state by position: 1st call, 2nd call, 3rd call…
Code
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}
hook calls this render
no calls yet
React's slots for <Profile>
no slots yet
what your variables get
—
First render, with showBio = true. React keeps a list of slots for each component on the page. The list is empty; this component has never rendered before.
1 / 8

From that one design choice come the two rules of hooks:

  1. Call hooks only at the top level of your component: not inside if, loops, nested functions, or after an early return. That keeps the call order identical on every render.
  2. 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.
✕ Don't
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
}
✓ Do
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.

Foundations§03

useState: a snapshot, not a variable

In one sentencesetCount(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.

Counter.jsx
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 like button that loses clicks
The like handler waits 0.8 s for a pretend server, then updates the count. Click it twice quickly.
0idle
Press ⚡: two clicks, 150 ms apart. You'd expect 2.

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.

✕ Reads the photo
async function like() {
  await saveLike();          // takes 0.8 s
  setLikes(likes + 1);       // likes is from the photo
}                            // where the click happened
✓ Asks for the latest
async function like() {
  await saveLike();
  setLikes((l) => l + 1);    // React passes the newest value
}                            // as l, whenever this runs

The 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:

What the setter actually queues
Three setter calls in one click. Step through both versions.
Code
1// in this render, likes is 0
2function plusThree() {
3 setLikes(likes + 1);
4 setLikes(likes + 1);
5 setLikes(likes + 1);
6}
this render's photo
likes = 0
React's update queue
empty
processing the queue
not yet
The user clicks +3. The handler runs inside the render where likes is 0. React's update queue for likes is empty.
1 / 6
Practitioner§04

Objects, arrays, lazy state and useReducer

In one sentenceReact decides whether anything changed by asking "is this the same object as before?", not "does it have the same contents?". So you change objects and arrays in state by making new ones.

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:

Push vs copy
Both buttons add a to-do. Watch the screen, the array in memory, and how many times React re-rendered.
On screenrenders: 1
  • ☐ buy milk
  • ☐ walk dog
The array in memory
2 items: 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.

replacing instead of changing
// 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 level

The 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:

✕ Parses on every render
const [draft, setDraft] = useState(
  JSON.parse(localStorage.getItem("draft")) // runs every render,
);                                          // result thrown away
✓ Parses once
const [draft, setDraft] = useState(
  () => JSON.parse(localStorage.getItem("draft")) // React calls it
);                                                // only once

Passing 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:

useReducer
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.

Practitioner§05

useEffect: synchronizing with the outside world

In one sentenceAn effect keeps something outside React (a connection, a timer, a browser API, a widget from another library) in sync with your props and state. It has a setup, a cleanup, and a list of values that decide when to re-sync.

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.

ChatRoom.jsx
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:

An effect's life: mount, update, unmount
The console output is copied from a real run (React 19.3, headless Chromium).
Code
1function ChatRoom({ roomId }) {
2 useEffect(() => {
3 const conn = connect(roomId); // setup
4 console.log("✅ connect", roomId);
5 return () => { // cleanup
6 conn.disconnect();
7 console.log("❌ disconnect", roomId);
8 };
9 }, [roomId]); // re-sync when roomId changes
10 return <h2>Welcome to {roomId}!</h2>;
11}
on screen
(nothing yet)
console
nothing logged yet
React's bookkeeping
deps: none yet
Mount. <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.
1 / 7

The dependency array, precisely

You writeThe 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:

A live effect, connected to a pretend chat server
Switch rooms, hide the room, then do it again with the cleanup removed.
<ChatRoom>mounted
Welcome to #general!
Server: open connections0
none
The server's log appears here.

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:

✕ Extra state + an effect
const [fullName, setFullName] = useState("");
useEffect(() => {
  setFullName(first + " " + last);
}, [first, last]);
// renders once with the OLD fullName, then again
✓ A plain variable
const fullName = first + " " + last;
// always up to date, one render, no state to sync

The 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-effect

2. 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.

Practitioner§06

When effects run: useEffect vs useLayoutEffect, measured

In one sentenceuseLayoutEffect 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?".

Where each piece runs, relative to the paint
update caused by a click (or key press)
React
render
commit
layout fx
useEffect
browser
until the frame
paint
update caused by a timer or a network response
React
render
commit
layout fx
useEffect
browser
paint here? maybe
paint
Boxes on one row with no gap are one task; the browser can only paint in the gaps between tasks. After a click, React pulls useEffect into the click's own task, so it always runs before the paint. After a timer or a network response, useEffect gets its own later task, and a frame may or may not squeeze in first.

Press both buttons a few times. The timestamps are real, taken in your browser with performance.now():

When do effects run? Measured in your browser
Each button re-renders a small component and timestamps every step with performance.now().
update #0
Press a button. The timeline appears here.

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 byWhat happeneduseEffect ran afternext frame began after
a click20 / 20: render → useLayoutEffect → useEffect, all in one task; then the frame0.1–1.4 ms1.2–13.9 ms
a timer15 / 20: useEffect in its own task, before the next frame
5 / 20: the frame (and paint) came first, then useEffect
0.2–2.3 ms0.7–10.3 ms

Two things to notice:

  1. After a click (or a key press), React runs useEffect immediately, 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.
  2. For other updates (timers, network responses), useEffect runs 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:

Tooltip.jsx
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.

Practitioner§07

Fetching in effects: the race you didn't see

In one sentenceWhen an effect starts a request, its cleanup must cancel or ignore that request. Otherwise an old, slow response can arrive after a newer one and overwrite it.

Here's the profile page everyone writes first:

Profile.jsx (buggy)
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:

Two requests, finishing out of order
you
Alice
Bob
Alice req
1.5 s on a slow server…
setProfile(Alice) ✗
Bob req
0.3 s
setProfile(Bob) ✓
screen
Bob
Alice ✗
You asked for Alice first, then Bob. Bob's answer arrives first and is shown, correctly. Then Alice's late answer arrives and calls 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:

Switch profiles quickly
Alice's server is slow (1.5 s), Bob's is fast (0.3 s). Pick Alice, then Bob right away.
Profile panelloading…
loading…
Requests and responses appear here.

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:

Profile.jsx: ignore stale responses
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:

Profile.jsx: abort stale requests
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.

Practitioner§08

useRef: a box that survives renders

In one sentenceuseRef 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.

Photos and one box
photo #1
count = 0
countRef ↓
photo #2
count = 1
countRef ↓
photo #3
count = 2
countRef ↓
📦 the one ref object, shared by every render: { current: 2 }
Each render's 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, silent

The two halves of that sentence, side by side:

A ref is a box React doesn't watch
Both buttons add 1 to a number. Only one of them makes React render.
What React renderedrenders: 1
state: 0
ref.current: 0 (as of the last render)
The real ref.current, right now
0
A DOM ref: <input ref={inputRef} />
useStateuseRef
Changing itsetX(v)ref.current = v
Causes a re-render?yesno
Value inside one renderfixed snapshotwhatever is in the box right now
Use it foranything shown on screenthings 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.

SearchBox.jsx
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.

Practitioner§09

Stale closures: the interval that always sees 0

In one sentenceA stale closure is a function from an old photo that's still being called. It reads that photo's values, no matter how many newer renders have happened.

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.

Ticker.jsx (buggy)
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:

The interval that always sees 0
A counter that should go up by 1 every 0.8 s. Start it, then switch between the fixes.
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 rebuilt
stopped (press ▶ Start)
Each tick of the interval logs what its function can see.

The 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-deps

3. 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:

Ticker.jsx with useEffectEvent (React 19.2+)
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.

Practitioner§10

useMemo and useCallback, honestly

In one sentenceuseMemo 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 + b25–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:

useMemo, measured
30,000 products. Search changes the result; the note field and the button only re-render the component.
23,604 matchesfilter + sort: … ms
blue blue blue #11549 · $58.86
blue blue blue #1169 · $177.43
blue blue blue #12144 · $145.88
blue blue blue #23958 · $183.24
blue blue blue #25738 · $85.11
blue blue blue #25792 · $118.88
re-renders requested: 0

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
useMemo0.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 + useCallback: they only work as a pair
The parent re-renders; do the rows?
rendered 1×
rendered 1×
rendered 1×
picked: —

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:

compiler output (abridged)
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.

Practitioner§11

Custom hooks: extract, share, test

In one sentenceA custom hook is a function whose name starts with 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):

useDebounce.js
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:

useFetch.js
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:

CitySearch.jsx
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:

useDebounce + an aborting fetch hook
Each search takes 0.45 s on a pretend server. Type 'barcelona' at normal speed, with and without debouncing.
Results
type something
requests started 0 · aborted 0 · finished 0
Network log.

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.

useDebounce.test.js
// @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§12

Staff level: fewer effects, real stores, transitions

In one sentenceIn a mature codebase, most 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 changesgive the component a key: <Profile key={userId} /> makes React start it fresh
run code after a click / submitthe event handler itself
fetch dataa data library or your framework's loader (below)
subscribe to a store / browser APIuseSyncExternalStore (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:

the same profile with TanStack Query
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):

src/lib/store.js (this site)
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 of value that's allowed to lag behind. Render the slow part with the copy.
  • useTransition() gives you startTransition(() => setX(…)) to mark a specific update as non-urgent, plus isPending to show a spinner.

The list below takes about 50 ms to render. Type quickly in both modes:

Typing next to a slow list
The list below takes about 50 ms to render. Type a few letters quickly in each mode and compare the delay.
keystroke → next frame: last — · average of 0 —
·0·1·2·3·4·5·6·7·8·9·10·11·12·13·14·15·16·17·18·19·20·21·22·23·24·25·26·27·28·29·30·31·32·33·34·35·36·37·38·39·40·41·42·43·44·45·46·47·48·49·50·51·52·53·54·55·56·57·58·59·60·61·62·63·64·65·66·67·68·69·70·71·72·73·74·75·76·77·78·79·80·81·82·83·84·85·86·87·88·89·90·91·92·93·94·95·96·97·98·99·100·101·102·103·104·105·106·107·108·109·110·111·112·113·114·115·116·117·118·119·120·121·122·123·124·125·126·127·128·129·130·131·132·133·134·135·136·137·138·139·140·141·142·143·144·145·146·147·148·149·150·151·152·153·154·155·156·157·158·159·160·161·162·163·164·165·166·167·168·169·170·171·172·173·174·175·176·177·178·179·180·181·182·183·184·185·186·187·188·189·190·191·192·193·194·195·196·197·198·199

What we measured in a production build (this machine, headless Chromium, typing 10 letters 30 ms apart):

ModeKeystroke → next frame
plainaverage 77–97 ms (two rounds): every keystroke waits for the whole list
useDeferredValueaverage 12–16 ms: about one frame; the list catches up afterwards
the deferred version
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).

Practitioner§13

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.

QUESTION 1 / 6
Three calls, one click
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?
QUESTION 2 / 6
Log right after setting
const [name, setName] = useState("Ana");
function onClick() {
  setName("Bo");
  console.log(name);
}
QUESTION 3 / 6
The effect with no array
useEffect(() => {
  document.title = `${count} new messages`;
});
QUESTION 4 / 6
Interval with empty deps
const [count, setCount] = useState(0);
useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(id);
}, []);
// What does the screen show after 5 seconds?
QUESTION 5 / 6
Which one prevents the wrong profile?

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?

QUESTION 6 / 6
Does memo skip this child?
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?
Answered 0 / 6
Foundations§14

Cheat sheet

📸
Each render is a photographProps, state and every function made during a render belong to that render. setX asks for the next photo; it doesn't edit this one.
🔢
Hooks are found by call orderTop level only: no hooks in if, loops or after an early return. Let eslint-plugin-react-hooks enforce it.
➕
New from old → updatersetX(x => x + 1) whenever the next value depends on the previous one.
🧊
Replace, don't mutateNew arrays and objects ([...a, x], { ...o, k: v }); React compares by identity.
🔌
Effects sync the outside worldSetup + matching cleanup + honest dependency array. Computing values and handling clicks are not effects.
🏁
Fetch in an effect? Abort in the cleanupOr ignore stale results. Better: a data library for server state.
📦
A ref is a box React doesn't watchDOM nodes, timer ids, latest values. Never what the screen shows.
⏱️
Memoize after you measureSlow calculation (≈1 ms+) or stable identity for a memo child / dependency. Otherwise skip it.
HookUse it forWatch out for
useStatevalues the screen showsstale reads after setX (§03); mutation (§04)
useReducerstate with rules, several fields that change togetherkeep the reducer pure
useEffectsyncing with something outside React while on screenmissing cleanup (§05); races (§07)
useLayoutEffectmeasuring the DOM before paintblocks the paint (§06)
useRefDOM nodes, ids, latest valuesreading it during render (§08)
useMemo / useCallbackslow calculations; stable identitycost on every render; pointless without a reader (§10)
useEffectEventreading latest values from inside an effect (React 19.2+)only call it from effects (§09)
useSyncExternalStoresubscribing to state outside Reactsnapshot must be === stable (§12)
useDeferredValue / useTransitionkeeping input fast next to slow renderingneeds memo on the slow part (§12)

Symptom → cause → fix

You see…Probably…Fix
Two quick clicks add 1, not 2setter reads the snapshotsetX(x => x + 1)
console.log after setX prints the old valuethe snapshot (that's normal)use the new value you computed, or read it next render
Screen doesn't update after a changemutated an array/object in statereplace it with a copy
A timer or listener always sees the first valuestale closureupdater, deps, ref, or useEffectEvent
Duplicate events / connections, memory growingmissing cleanupreturn a cleanup that undoes the setup
Wrong user's data after switching fastrequest raceAbortController or ignore flag in the cleanup
An effect runs on every renderobject/function in the depsdepend on primitives, or memoize the object
Everything runs twice in developmentStrictMode checking your cleanupsfix the cleanup; production runs once
UI flickers into placemeasured the DOM in useEffectuseLayoutEffect
memo child still re-rendersa new function/object prop each renderuseCallback / useMemo for that prop
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Frontend & BrowserState Management
Read →