React NotesRohit’s interview study guide
Chapter 07

Hooks In Depth & Custom Hooks

The rules of hooks and why they exist, useReducer, useContext, useId and useSyncExternalStore, then writing your own hooks — useFetch with AbortController, useDebounce/useThrottle, useLocalStorage — and how React stores hook state under the hood.

10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Rules of hooks

Hooks come with two rules, and every hook bug you'll see in an interview breaks one of them:

  1. Only call hooks at the top level of a component or custom hook — never inside if, loops, nested functions, or after an early return.
  2. Only call hooks from React functions — function components and custom hooks. Not from plain helper functions, event handlers or class components.

The reason is simple once you see it: React doesn't know your hooks by name, only by position. When Profile calls useState("Rohit") and then useState(0), React stores "slot 1: "Rohit", slot 2: 0" against that component. On the next render it hands the slots back in the same order: the first useState call gets slot 1, the second gets slot 2. A good mental model is a row of numbered lockers: every render, React walks down the row from locker 1, and each hook call opens the next locker. If a condition makes one call disappear, every hook after it opens the wrong locker.

From my notes: "Another example of when STRICT MODE will warn you (Hooks only at top level)" — the example itself was a screenshot that didn't come through. Correcting my notes: it isn't Strict Mode that catches this. React's development build checks the hook order on every render, with or without <StrictMode>, and the eslint-plugin-react-hooks rule rules-of-hooks flags it in your editor before the code even runs.

Search.jsx
import { useState, useEffect } from "react";

function Search({ advanced }) {
  const [query, setQuery] = useState("");
  if (advanced) {
    useEffect(() => {
      // ❌ a hook inside a condition
    }, []);
  }
  const [page, setPage] = useState(1);
  return <p>{query}{page}</p>;
}

// First render:  <Search advanced={false} />
// Second render: <Search advanced={true} />
What’s happening
  1. First render with advanced={false}: React sees two hook calls — locker 1 is a useState holding "", locker 2 is a useState holding 1.
  2. Second render with advanced={true}: the calls are now useState, useEffect, useState. The useEffect call opens locker 2 — which holds page's state, not an effect.
  3. React's dev build notices the type of hook in locker 2 changed and logs: "React has detected a change in the order of Hooks called by Search. This will lead to bugs and errors if not fixed." followed by a table showing 2. useState in the previous render and useEffect in the next, with ^^^^ under that row.
  4. Then the render actually crashes. In our test React threw "Cannot read properties of undefined (reading 'length')" — the effect code tried to read a dependency array from a slot that was never an effect. There's also a third hook call with no locker at all.
  5. This is why the rule exists: the hook list is matched up by order, so the order must be identical on every render.

The fix is always to keep the hook call unconditional and move the condition inside it (or split the component):

Search.jsx
function Search({ advanced }) {
  const [query, setQuery] = useState("");
  useEffect(() => {
    if (!advanced) return; // ✅ the condition lives inside the effect
    // ...advanced-only work
  }, [advanced]);
  const [page, setPage] = useState(1);
  return <p>{query}{page}</p>;
}
What’s happening
  1. Every render now calls exactly three hooks in the same order: useState, useEffect, useState. The lockers always line up.
  2. The effect function still runs after each render where advanced changed, but it returns early when there's nothing to do. Skipping work is fine; skipping the hook call is not.
  3. advanced is in the dependency array because the effect reads it — the effect re-runs when it flips.
  4. If the advanced part needs a lot of its own state, the cleaner fix is a separate <AdvancedFilters /> component rendered conditionally. A component that isn't rendered has no lockers at all, so conditionally rendering components is always safe.

The errors you'll meet for these mistakes, as React 19.3 prints them:

MistakeWhat React says
A different hook type in the same position"React has detected a change in the order of Hooks called by Search…" (dev warning with a table)
A condition adds a hook"Rendered more hooks than during the previous render."
An early return skips a hook"Rendered fewer hooks than expected. This may be caused by an accidental early return statement."
A hook called outside a component (helper function, class, event handler)"Invalid hook call. Hooks can only be called inside of the body of a function component."
AdvancedThe silent version: same hook types, wrong values

The order warning only fires when the type of hook in a slot changes. If the hooks are all useState, nothing warns — you just get the wrong data:

Profile.jsx
function Profile({ showBio }) {
  const [name] = useState("Rohit");
  if (showBio) {
    const [bio] = useState("Android + React"); // ❌
    return <p>{name}: {bio}</p>;
  }
  const [likes] = useState(3);
  return <p>{name} has {likes} likes</p>;
}

// <Profile showBio={false} />  →  "Rohit has 3 likes"
// <Profile showBio={true} />   →  "Rohit: 3"   (no warning, no error)
What’s happening
  1. First render (showBio={false}): two useState calls. Locker 1 = "Rohit", locker 2 = 3 (likes).
  2. Second render (showBio={true}): the second useState call is now the bio one. It opens locker 2 — which already exists and holds 3.
  3. On re-renders useState ignores its argument ("Android + React") because the locker already has a value, so bio is 3.
  4. Both slots are useState, so the type check passes and React logs nothing. The page renders "Rohit: 3". We confirmed this in the sandbox: zero console errors.
  5. This is the scariest form of the bug — it doesn't crash, it shows wrong data. It's the strongest argument for running the ESLint plugin, which catches the conditional call statically.

Rule 2 in action — a hook called from an ordinary function:

React
function getName() {
  const [name] = useState("x"); // ❌ not a component, not a hook
  return name;
}

getName();
// Error: Invalid hook call. Hooks can only be called inside of the body of a function component.
// This could happen for one of the following reasons:
// 1. You might have mismatching versions of React and the renderer (such as React DOM)
// 2. You might be breaking the Rules of Hooks
// 3. You might have more than one copy of React in the same app
What’s happening
  1. getName() is called directly, not rendered by React, so there's no component "currently rendering" and no locker row to use.
  2. Internally React keeps a "dispatcher" that's only set while it's rendering a component (see How hooks work under the hood). Outside a render it's empty, so useState throws.
  3. The three listed reasons are worth knowing for interviews: the same error appears when two copies of React end up in the bundle (common in monorepos and with npm link), because the copy that useState came from isn't the one that's rendering.
  4. The fix: rename the helper to useName and call it from a component's top level — then it's a custom hook and it's allowed to call hooks.
WhereAllowed?
Top level of a function component✅
Top level of a custom hook (useSomething)✅
Inside if, for, while, switch❌
After an early return❌
Inside an event handler or setTimeout callback❌
Inside the callback of useEffect, useMemo, useReducer❌
In a class component❌
use(promiseOrContext) inside an if✅ (React 19)
Hooks in a loop
React
function Survey({ questions }) {
  const answers = [];
  for (const q of questions) {
    answers.push(useState(""));
  }
  // ...
}

Is this a bug?

Show answer

It works while questions.length never changes, and breaks the moment it does.

What’s happening
  1. With 3 questions, every render makes 3 useState calls in the same order, so lockers 1–3 line up and nothing goes wrong.
  2. If a question is added, the next render makes 4 calls: React throws "Rendered more hooks than during the previous render."
  3. If one is removed, it makes 2 calls: "Rendered fewer hooks than expected." And if questions are reordered, answer 2's state silently ends up on a different question.
  4. Fix it either with one state holding an object keyed by question id — useState({}) and setAnswers(a => ({ ...a, [id]: value })) — or by rendering a <Question key={q.id} /> component per question, each with its own useState. With keys, React tracks each child's state by identity, not by position.

#useReducer

useReducer is useState with the update rules moved out of the component. Instead of calling setItems([...items, newItem]) in five places, components dispatch a description of what happened — { type: "added", item } — and one pure function, the reducer, decides what the next state is: (state, action) => newState.

From my notes: "useReducer provides an alternative to useState for handling complex state logic, particularly when state updates are based on actions." That's exactly it. A good mental model is a bank teller: you don't walk into the vault and change the ledger yourself, you hand over a slip that says "deposit 50". The teller applies the bank's rules and writes the new balance. Every change goes through one window, so the rules live in one place and you can test them without a bank.

cartReducer.js
export const initialCart = { items: [], coupon: null };

export function cartReducer(state, action) {
  switch (action.type) {
    case "added": {
      const existing = state.items.find((i) => i.id === action.item.id);
      if (existing) {
        return {
          ...state,
          items: state.items.map((i) =>
            i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i
          ),
        };
      }
      return { ...state, items: [...state.items, { ...action.item, qty: 1 }] };
    }
    case "removed":
      return { ...state, items: state.items.filter((i) => i.id !== action.id) };
    case "couponApplied":
      return { ...state, coupon: action.code };
    case "cleared":
      return initialCart;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

export const cartTotal = (state) => {
  const sum = state.items.reduce((total, i) => total + i.price * i.qty, 0);
  return state.coupon === "HALF" ? sum / 2 : sum;
};
What’s happening
  1. The reducer receives the current state and one action, and must return the next state. It never changes state in place: every branch builds a new object with spread (...state) and new arrays with map/filter/[...].
  2. "added" has two paths. If the product is already in the cart, map copies the array and replaces just that item with a copy whose qty is +1. Otherwise it appends { ...action.item, qty: 1 }. Adding the book twice goes [] → [{ Book, qty: 1 }] → [{ Book, qty: 2 }].
  3. "cleared" returns initialCart itself. That's safe precisely because nothing ever mutates it.
  4. The default branch throws. A typo like dispatch({ type: "add" }) fails loudly with "Unknown action: add" instead of silently doing nothing. (Redux reducers return state in default instead, because every Redux reducer sees every action — see Redux core concepts.)
  5. cartTotal is a derived value: computed from state when needed, never stored. Storing total in state would be one more thing to keep in sync.

The component just wires buttons to actions:

Cart.jsx
import { useReducer } from "react";
import { cartReducer, initialCart, cartTotal } from "./cartReducer";

const book = { id: "b1", name: "Book", price: 20 };
const pen = { id: "p1", name: "Pen", price: 5 };

export function Cart() {
  const [cart, dispatch] = useReducer(cartReducer, initialCart);

  return (
    <div>
      <button onClick={() => dispatch({ type: "added", item: book })}>Add book</button>
      <button onClick={() => dispatch({ type: "added", item: pen })}>Add pen</button>
      <button onClick={() => dispatch({ type: "couponApplied", code: "HALF" })}>Apply HALF</button>
      <button onClick={() => dispatch({ type: "cleared" })}>Clear</button>
      <ul>
        {cart.items.map((i) => (
          <li key={i.id}>
            {i.name} × {i.qty}
          </li>
        ))}
      </ul>
      <p>Total: ${cartTotal(cart)}</p>
    </div>
  );
}
What’s happening
  1. useReducer(cartReducer, initialCart) returns the current state and a dispatch function — the same shape as useState's [value, setValue].
  2. Clicking "Add book" calls dispatch({ type: "added", item: book }). React queues the action and re-renders; during that render it runs cartReducer(currentCart, action) to get the new cart.
  3. In the test we clicked Add book, Add book, Add pen: the list showed "Book × 2" and "Pen × 1" and the total "Total: $45". Apply HALF → "Total: $22.5". Clear → "Total: $0".
  4. Notice what the component doesn't contain: no spreading, no find, no map over items. It says what happened; the reducer owns how state changes. That separation is the whole value of useReducer.

Because the reducer is a plain function, you can test the rules without rendering anything:

cartReducer.test.js
import { cartReducer, initialCart, cartTotal } from "./cartReducer";

test("adding the same item twice bumps qty", () => {
  const book = { id: "b1", name: "Book", price: 20 };
  let s = cartReducer(initialCart, { type: "added", item: book });
  s = cartReducer(s, { type: "added", item: book });

  expect(s.items).toEqual([{ id: "b1", name: "Book", price: 20, qty: 2 }]);
  expect(cartTotal(s)).toBe(40);
  expect(initialCart.items).toEqual([]); // the original was never touched
  expect(() => cartReducer(s, { type: "teleported" })).toThrow("Unknown action: teleported");
});
What’s happening
  1. Each call feeds the previous result back in — exactly what React does on each dispatch, just by hand.
  2. s.items ends as one book with qty: 2, and the total is 40.
  3. initialCart.items is still [], which proves the reducer didn't mutate its input.
  4. Testing state logic this way is fast and needs no DOM. It's one of the main practical reasons teams move complex state into reducers.

When to reach for which:

SituationPick
One or two independent values (a toggle, an input)useState
Several values that change together (status, data, error)useReducer
The next state depends on the previous state in non-trivial waysuseReducer
Many event handlers update the same state differentlyuseReducer
You want to unit test the update rulesuseReducer
State shared across distant componentsuseReducer + context — see Context with useReducer
AdvancedLazy init, stable dispatch and bailing out

Lazy initial state. useReducer(reducer, initialArg, init) calls init(initialArg) once, on the first render, and never again. Use it when computing the initial state is expensive (reading localStorage, parsing a big prop). In the sandbox an init function wrapped in a counter ran exactly 1 time across three renders. Without the third argument, useReducer(reducer, createInitial(saved)) would call createInitial on every render and throw the result away.

dispatch never changes. React gives you the same dispatch function for the component's whole life — the sandbox collected dispatch across renders into a Set and got size 1. So it's safe to leave out of dependency arrays and to pass down to memoised children; it won't cause re-renders. This is the property that makes Context with useReducer work so well.

Returning the same state bails out. If the reducer returns the exact same object (Object.is equal), React skips re-rendering the children and running effects. One detail that surprises people: with useReducer, React may still call your component function once before it discovers nothing changed. Measured in the sandbox:

React
function C() {
  parent++;
  const [s, dispatch] = useReducer((st, a) => (a.type === "noop" ? st : st + 1), 0);
  return (
    <>
      <button onClick={() => dispatch({ type: "noop" })}>noop</button>
      <button onClick={() => dispatch({ type: "inc" })}>inc</button>
      <Child />
    </>
  );
}
// after inc:    parent 2, child 2
// after noop:   parent 3, child 2
// after noop:   parent 4, child 2
What’s happening
  1. "inc" changes state 0 → 1, so both C and Child render: counts 2, 2.
  2. "noop" returns the same st. React still runs C (parent 3) because it can only learn the reducer's answer by rendering, but then sees the state is unchanged and doesn't render Child (child stays 2) and commits nothing.
  3. Every further noop does the same (parent 4, child 2). useState is slightly smarter: after one such render, setS(sameValue) is answered "eagerly" before rendering at all — in the same experiment useState stopped at parent 3.
  4. Take-away: returning the same state is cheap, but don't treat "my component function ran" as "React re-rendered the tree".

#useContext

useContext(SomeContext) reads a value that a component somewhere above provided, without passing it through every component in between. It's how themes, the signed-in user, locale and feature flags reach deeply nested components.

The mental model is a radio station. A provider broadcasts a value on a frequency (the context object). Any component below it that tunes in with useContext hears the nearest transmitter above it. If there's no transmitter at all, it hears the default value baked into createContext(default). When the broadcast changes, every listener re-renders.

This topic is the hook itself. How to design state around context — custom hooks, splitting, avoiding re-renders — is in The Context API and the topics after it.

theme.jsx
import { createContext, useContext } from "react";

export const ThemeContext = createContext("light"); // default, used only without a provider

export function ThemedButton({ children }) {
  const theme = useContext(ThemeContext);
  return <button className={`btn-${theme}`}>{children} ({theme})</button>;
}

export function App() {
  return (
    <>
      <ThemedButton>Outside</ThemedButton>
      <ThemeContext value="dark">
        <ThemedButton>Page</ThemedButton>
        <section>
          <ThemeContext value="high-contrast">
            <ThemedButton>Dialog</ThemedButton>
          </ThemeContext>
        </section>
      </ThemeContext>
    </>
  );
}

// Rendered:
// Outside (light)          class=btn-light
// Page (dark)              class=btn-dark
// Dialog (high-contrast)   class=btn-high-contrast
What’s happening
  1. createContext("light") creates the "frequency". The argument is the default value, and it's used only by components with no provider above them.
  2. The first ThemedButton sits outside every provider, so useContext(ThemeContext) returns the default: "Outside (light)".
  3. "Page" is inside <ThemeContext value="dark">, so it gets "dark". The <section> in between doesn't know or care about the theme — that's the point.
  4. "Dialog" has two providers above it. React walks up the tree and uses the nearest one: "high-contrast". Providers can be nested and overridden for a subtree.
  5. In React 19 the context object itself is the provider (<ThemeContext value=…>). Older code writes <ThemeContext.Provider value=…>, which still works in React 19 and is shown below.

React 18 and earlier, plus the class-component way, which you'll still meet in older codebases:

LegacyTheme.jsx
// React 18 and earlier: the .Provider component, and the render-prop Consumer
export function LegacyApp() {
  return (
    <ThemeContext.Provider value="dark">
      <ThemeContext.Consumer>
        {(theme) => <span>consumer sees {theme}</span>}
      </ThemeContext.Consumer>
    </ThemeContext.Provider>
  );
}

// Class components read one context through static contextType
class OldPanel extends Component {
  static contextType = ThemeContext;
  render() {
    return <i>class sees {this.context}</i>;
  }
}
What’s happening
  1. <ThemeContext.Provider value="dark"> is the pre-19 spelling of the provider. React 19 still supports it, and the React docs say it will be deprecated in a future version, so new code uses <ThemeContext value>.
  2. <ThemeContext.Consumer> takes a function as its child and calls it with the current value — "consumer sees dark". Before hooks (React 16.8) this render-prop was the only way for function components to read context.
  3. static contextType = ThemeContext makes this.context available in a class component — "class sees dark". A class can read only one context this way; reading two meant nesting Consumers.
  4. All three were tested in the sandbox with React 19.3. useContext replaced the Consumer because it's a single line and can read any number of contexts.

React 19 also lets you read context with use(), which — unlike useContext — can come after an early return:

Hint.jsx
import { use } from "react";

export function Hint({ show }) {
  if (!show) return null;
  const theme = use(ThemeContext); // ✅ allowed after a return; useContext here would break the rules
  return <small>hint in {theme}</small>;
}
What’s happening
  1. When show is false the component returns before reading context, so no work is done.
  2. When show is true, use(ThemeContext) reads the nearest provider exactly like useContext — inside <ThemeContext value="dark"> it rendered "hint in dark".
  3. useContext in the same place would violate the Rules of hooks because the early return makes the call conditional. use is designed to be called conditionally.
  4. In day-to-day code useContext is still the usual choice; use is handy when the read genuinely depends on a condition.

#useId

Added

useId() returns a string ID that's unique to this component instance and identical on the server and the client. You use it to connect elements for accessibility: <label htmlFor> to <input id>, aria-describedby to a hint, aria-labelledby to a heading.

Why not a counter or a random ID? A component library's <EmailField> might appear twice on a page, so a hard-coded id="email" would clash. Math.random() or a module-level counter gives different values on the server and in the browser, so hydration fails with a mismatch. The mental model: useId derives the ID from the component's position in the tree ("the first child of the second child of the root"), not from a global count. Server and client build the same tree, so they arrive at the same ID.

EmailField.jsx
import { useId } from "react";

function EmailField({ label }) {
  const id = useId();
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} type="email" aria-describedby={`${id}-hint`} />
      <p id={`${id}-hint`}>We never share it.</p>
    </div>
  );
}

export function Form() {
  return (
    <form>
      <EmailField label="Work email" />
      <EmailField label="Personal email" />
    </form>
  );
}

// Client render (React 19.3):  _r_0_  and  _r_1_   aria-describedby="_r_0_-hint"
// renderToString:              _R_1_  and  _R_2_
What’s happening
  1. Each EmailField calls useId() once and gets its own ID: the first instance _r_0_, the second _r_1_. Clicking "Work email" focused the first input in the test, so the label wiring works.
  2. One ID is enough for several elements: append suffixes like ${id}-hint. Don't call useId once per element.
  3. On the server, renderToString produced _R_1_ and _R_2_ — a capital R marks server-generated IDs. When the browser hydrates that HTML, React reuses the server's IDs, so the attributes match.
  4. Treat the format as opaque. It was :r0: in React 18, «r0» in React 19.1, and _r_0_ from React 19.2 (changed so IDs are valid CSS identifiers and work with View Transitions).
AdvancedSeveral React roots on one page

If two separate React apps (two createRoot calls, e.g. micro-frontends) render on the same page, both start their IDs from the same place and can collide. Give each root a prefix: createRoot(el, { identifierPrefix: "app1-" }). In the sandbox the field's ID became _app1-r_2_. The same option exists on the server renderers, and both sides must use the same prefix.

#useSyncExternalStore

Added

Most state lives inside React (useState, useReducer). Some lives outside: the browser's online status, window.matchMedia, localStorage, a WebSocket client, or a store module like Redux or Zustand. useSyncExternalStore is the official way to read such a value and re-render when it changes.

The mental model: React asks the store two questions. "How do I get told when you change?" — that's subscribe(callback), which returns an unsubscribe function. And "what is your value right now?" — that's getSnapshot(). React calls getSnapshot during render, re-renders when the callback fires and the snapshot changed, and unsubscribes on unmount. Because React reads the snapshot itself during rendering, it can guarantee that every component in one render sees the same value — no tearing (see Concurrent features and tearing).

useOnlineStatus.js
import { useSyncExternalStore } from "react";

function subscribe(callback) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}

const getSnapshot = () => navigator.onLine;
const getServerSnapshot = () => true; // assume online while server rendering

export function useOnlineStatus() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

// function StatusBar() {
//   const online = useOnlineStatus();
//   return <p>{online ? "✅ Online" : "❌ Offline"}</p>;
// }
What’s happening
  1. On mount, React calls getSnapshot() → navigator.onLine is true, so StatusBar renders "✅ Online". Then it calls subscribe(callback), which adds the two window listeners.
  2. When the network drops, the browser fires offline, which calls React's callback. React calls getSnapshot() again, gets false, sees it differs from true, and re-renders: "❌ Offline". The test simulated this with window.dispatchEvent(new Event("offline")).
  3. subscribe and getSnapshot are defined outside the component, so they're the same functions every render. If subscribe were a new function each render, React would unsubscribe and resubscribe on every render.
  4. getServerSnapshot is used during server rendering and hydration, where navigator doesn't exist. Without it, server rendering a component that uses this hook throws.
  5. Compare with the useEffect + useState version: it renders once with a guessed value, then subscribes later in an effect, and can miss a change that happens in between. useSyncExternalStore closes that gap.

The same hook can read a store you write yourself. This is, in miniature, how Redux and Zustand plug into React:

createStore.js
export function createStore(initialState) {
  let state = initialState;
  const listeners = new Set();
  return {
    getState: () => state,
    setState(update) {
      state = typeof update === "function" ? update(state) : update;
      listeners.forEach((listener) => listener());
    },
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

// const counterStore = createStore({ count: 0, user: "Rohit" });
// function useCounterStore(selector) {
//   return useSyncExternalStore(counterStore.subscribe, () => selector(counterStore.getState()));
// }
// function Count() { const c = useCounterStore((s) => s.count); return <p>count {c}</p>; }
// function User()  { const u = useCounterStore((s) => s.user);  return <p>user {u}</p>; }
//
// After two "+1" clicks:  renders  Count 3, User 1
What’s happening
  1. The store is plain JavaScript: a variable, a set of listeners, and setState that replaces the state and calls every listener. React doesn't own it.
  2. useCounterStore(selector) subscribes to the store and uses selector(state) as the snapshot, so each component reads only the piece it cares about.
  3. Each "+1" click replaces the state with { ...s, count: s.count + 1 } and notifies both components. React re-runs both snapshots.
  4. Count's snapshot changes (0 → 1 → 2), so it re-renders: 1 mount + 2 updates = 3 renders. User's snapshot is still the string "Rohit" — Object.is equal — so React skips it: 1 render total.
  5. That's the essence of useSelector in react-redux: a selector per component, and a re-render only when the selected value changes. Indeed, the react-redux 9 stack traces captured for the next chapter's tests show useSyncExternalStoreWithSelector under useSelector.

You'll rarely call useSyncExternalStore directly in app code — useSelector, Zustand's hooks and TanStack Query use it for you. Reach for it when you wrap a browser API or a non-React library yourself.

#Custom hooks

A custom hook is a function whose name starts with use and that calls other hooks. It lets you pull stateful logic — state, effects, refs, subscriptions — out of a component and reuse it. Before hooks, the same reuse took higher-order components or render props (see HOCs vs render props vs hooks); custom hooks replaced most of them.

The key fact, and a common interview question: custom hooks share logic, not state. The mental model is a recipe, not a pot of soup. Two components that use useWindowWidth() each follow the recipe and cook their own pot: each gets its own useState, its own effect, its own event listener. If you want them to share one value, that's a job for context or a store.

WindowWidth.jsx
import { useState, useEffect } from "react";

// Before: the logic is stuck inside one component
function HeaderBefore() {
  const [width, setWidth] = useState(window.innerWidth);
  useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  return <header>{width < 600 ? "☰ Menu" : "Home · Docs · Blog"}</header>;
}

// After: the same lines, moved into a hook
export function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  return width;
}

export function Header() {
  const width = useWindowWidth();
  return <header>{width < 600 ? "☰ Menu" : "Home · Docs · Blog"}</header>;
}

export function Footer() {
  const width = useWindowWidth();
  return <footer>{width}px wide</footer>;
}
What’s happening
  1. useWindowWidth is literally the state and effect from HeaderBefore, cut and pasted into a function that returns width. No new React API is involved — that's why custom hooks are easy.
  2. When Header renders, useWindowWidth() runs as part of Header's render: its useState and useEffect take lockers in Header's hook list, exactly as if they were written inline.
  3. Footer calls the same hook and gets its own useState and its own resize listener. With the window at 1024 px, the page showed "Home · Docs · Blog" and "1024px wide".
  4. After a resize to 500 px, both listeners fire, both components update: "☰ Menu" and "500px wide". Two listeners, two states, one recipe.
  5. The use prefix isn't decoration: it tells React's linter to apply the Rules of hooks inside the function and at its call sites.

Each call is independent, even twice in the same component:

Faq.jsx
import { useState, useCallback } from "react";

export function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle];
}

export function Faq() {
  const [shippingOpen, toggleShipping] = useToggle();
  const [returnsOpen, toggleReturns] = useToggle(true);
  return (
    <>
      <button onClick={toggleShipping}>Shipping {shippingOpen ? "▲" : "▼"}</button>
      <button onClick={toggleReturns}>Returns {returnsOpen ? "▲" : "▼"}</button>
    </>
  );
}
What’s happening
  1. Faq calls useToggle twice, so its hook list has two useState slots (plus two useCallback slots) — one pair per call.
  2. Shipping starts false ("▼"), Returns starts true ("▲"). Clicking Shipping flips only the first slot: "Shipping ▲", and Returns stays "▲".
  3. Clicking Returns flips only its own slot to "▼"; Shipping is still "▲". The test checked each state after each click.
  4. useCallback with [] keeps toggle the same function across renders (the test asserted result.current[1] was identical after a rerender), and the functional update v => !v means it never needs the current on value. Callers can safely put toggle in dependency arrays.
  5. Returning an array lets each caller name the values (shippingOpen, toggleShipping), like useState. For three or more values, return an object instead so callers don't depend on the order.

Testing a hook on its own uses renderHook from React Testing Library:

useWindowWidth.test.jsx
import { renderHook, act } from "@testing-library/react";
import { useWindowWidth } from "./WindowWidth";

test("tracks the window width and cleans up", () => {
  window.innerWidth = 800;
  const { result, unmount } = renderHook(() => useWindowWidth());
  expect(result.current).toBe(800);

  act(() => {
    window.innerWidth = 320;
    window.dispatchEvent(new Event("resize"));
  });
  expect(result.current).toBe(320);

  const removeSpy = vi.spyOn(window, "removeEventListener");
  unmount();
  expect(removeSpy).toHaveBeenCalledWith("resize", expect.any(Function));
});
What’s happening
  1. renderHook renders a tiny invisible test component that calls your hook and stores the latest return value in result.current.
  2. The first value is 800, read by the lazy useState(window.innerWidth).
  3. Changing the width and firing resize inside act() makes React process the state update before the next line; result.current is now 320.
  4. unmount() runs the effect cleanup, and the spy proves the listener was removed. A leaked listener would keep calling setWidth on a component that's gone.
  5. renderHook has been part of @testing-library/react since v13.1. My notes used the old @testing-library/react-hooks package; that's deprecated (it was built for React 17's rendering APIs) — see Testing custom hooks.

Practical guidance:

  • Extract a hook when logic repeats or when it hides noise. A component that reads const width = useWindowWidth() says what it needs, not how it's tracked.
  • If the function doesn't call any hooks, it isn't a hook. Name it formatPrice, not useFormatPrice, so it can be called anywhere, including in conditions and loops.
  • Keep hooks focused. useWindowWidth, useDebounce, useLocalStorage compose well. A 300-line useEverything doesn't.
  • Hooks can call hooks. useFetch can use useDebounce; that's composition, and the Rules of hooks apply all the way down.

#useFetch: a data-fetching hook

useFetch(url) is the classic interview custom hook: it wraps fetch in state (data, loading, error) and an effect, so a component can write const { data, loading, error } = useFetch(url). It's a perfect exercise because a naive version is easy and a correct version has to deal with HTTP errors, URL changes, races and unmounting.

The mental model: every time url changes, the hook starts a new request and owns only that request. When a newer request starts, or the component goes away, the old request's answer is no longer wanted — so it must be cancelled or ignored.

From my notes, the first version:

useFetchOriginal.js
import { useState, useEffect } from "react";

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url);
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err);
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, [url]);

  return { data, loading, error };
}
What’s happening
  1. The structure is right: three pieces of state, an effect keyed on [url], and an inner async function because the effect callback itself mustn't be async (see Async code in effects).
  2. Bug 1 — HTTP errors look like data. fetch only rejects on network failure; a 404 or 500 still resolves. We returned a 404 with { message: "nope" } and the hook gave {"data":{"message":"nope"},"loading":false,"error":null}. It needs a response.ok check.
  3. Bug 2 — races. We rendered with /api/users/1, switched to /api/users/2, answered request 2 first and request 1 last. The hook ended with url=/api/users/2 but data = {"id":1,"name":"Ada"} — the slow, stale response overwrote the right one.
  4. Bug 3 — stale state on URL change. loading starts true once and is never set back, so changing url shows the previous user with loading: false while the new one loads; an old error also sticks around.
  5. Nothing cancels the request when the component unmounts, so the browser keeps downloading a response nobody will use.

My notes' second version (marked "IMPORTANTTTTT: Abort Controller to prevent memory leaks") added an AbortController and the response.ok check, which fixes bugs 1, 2 and the unmount case. Here's that version tidied up, with one state object so the three values always change together:

useFetch.js
import { useState, useEffect } from "react";

export function useFetch(url) {
  const [state, setState] = useState({ data: null, loading: true, error: null });

  useEffect(() => {
    const controller = new AbortController();
    setState({ data: null, loading: true, error: null });

    async function load() {
      try {
        const response = await fetch(url, { signal: controller.signal });
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        const data = await response.json();
        setState({ data, loading: false, error: null });
      } catch (err) {
        if (err.name === "AbortError") return; // we cancelled it: ignore
        setState({ data: null, loading: false, error: err });
      }
    }

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

  return state;
}
What’s happening
  1. Each effect run creates its own AbortController and passes its signal to fetch. The controller is a closure variable of that run, so the cleanup function aborts exactly that request and no other.
  2. setState({ data: null, loading: true, error: null }) at the start resets everything for the new URL (bug 3). Keeping the three values in one object means you can never render loading: false with old data by forgetting one setter.
  3. if (!response.ok) throw … turns 404/500 into an error (bug 1). The test with a 404 now gives error.message === "HTTP 404" and loading: false.
  4. When url changes, React runs the previous run's cleanup first: controller.abort() makes the old fetch reject with an AbortError, and the catch returns without touching state. In the race test the old request was aborted, and the hook kept { id: 2, name: "Grace" } even when the "slow" answer for user 1 arrived afterwards (bug 2).
  5. On unmount the same cleanup aborts the in-flight request, so the browser stops downloading it. A real network failure (TypeError: Failed to fetch) isn't an AbortError, so it still lands in error.

Using it — note the component knows nothing about effects or aborting:

UserCard.jsx
import { useFetch } from "./useFetch";

export function UserCard({ id }) {
  const { data: user, loading, error } = useFetch(`/api/users/${id}`);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <h2>{user.name}</h2>;
}
What’s happening
  1. On the first render loading is true, so the card shows "Loading...". The effect starts the request after the render is committed.
  2. When the response arrives, one setState replaces the whole object, and the card renders user.name.
  3. If the parent changes id, the url string changes, the effect re-runs: the old request is aborted, state resets to loading, and the new request starts.
  4. Because the hook returns a fresh { data: null, loading: true, error: null } before every request, user is never the previous user's data while loading.

And the tests. Correcting my notes: they used renderHook and waitForNextUpdate from @testing-library/react-hooks. That package is deprecated; renderHook now comes from @testing-library/react, and you wait with waitFor/findBy* or resolve your fake inside act():

useFetch.test.jsx
import { renderHook, act } from "@testing-library/react";
import { useFetch } from "./useFetch";

// A fake fetch whose responses the test controls, and which honours AbortSignal
function createFakeFetch() {
  const calls = [];
  const fetch = vi.fn((url, { signal } = {}) =>
    new Promise((resolve, reject) => {
      const call = { url, aborted: false };
      call.respond = (body, status = 200) =>
        resolve({ ok: status >= 200 && status < 300, status, json: async () => body });
      signal?.addEventListener("abort", () => {
        call.aborted = true;
        reject(new DOMException("The operation was aborted.", "AbortError"));
      });
      calls.push(call);
    })
  );
  return { fetch, calls };
}

let fake;
beforeEach(() => {
  fake = createFakeFetch();
  vi.stubGlobal("fetch", fake.fetch);
});
afterEach(() => vi.unstubAllGlobals());

test("loads data", async () => {
  const { result } = renderHook(() => useFetch("/api/users/1"));
  expect(result.current).toEqual({ data: null, loading: true, error: null });

  await act(async () => fake.calls[0].respond({ id: 1, name: "Ada" }));
  expect(result.current).toEqual({ data: { id: 1, name: "Ada" }, loading: false, error: null });
});

test("a stale response can't overwrite a newer one", async () => {
  const { result, rerender } = renderHook(({ url }) => useFetch(url), {
    initialProps: { url: "/api/users/1" },
  });
  rerender({ url: "/api/users/2" });
  expect(fake.calls[0].aborted).toBe(true);

  await act(async () => fake.calls[1].respond({ id: 2, name: "Grace" }));
  await act(async () => fake.calls[0].respond({ id: 1, name: "Ada" })); // too late
  expect(result.current.data).toEqual({ id: 2, name: "Grace" });
});
What’s happening
  1. createFakeFetch returns a vi.fn that records each call and leaves the promise pending until the test calls respond. That lets the test decide the order responses arrive in — the only way to test a race deterministically.
  2. It also listens to the signal: aborting rejects with a real DOMException named "AbortError", just like the browser.
  3. "loads data" checks the initial { data: null, loading: true, error: null }, then resolves inside act(async …) so React flushes the state update before the assertion.
  4. The race test changes url with rerender, which runs the cleanup — fake.calls[0].aborted is true. Answering request 2 and then request 1 leaves Grace in place.
  5. All 8 useFetch tests passed in the sandbox (success, 404, network failure, race, unmount, Strict Mode). The same race test run against the original hook showed Ada — that's how we know the original was buggy.
AdvancedThe "ignore" flag: my notes' second way

My notes listed two ways to handle unmounts: the AbortController, and "use a variable and only if mounted update state". Here's the variable version as React's docs write it today — a per-run flag rather than a component-wide isMounted:

useFetchIgnore.js
import { useState, useEffect } from "react";

export function useFetchIgnore(url) {
  const [state, setState] = useState({ data: null, loading: true, error: null });

  useEffect(() => {
    let ignore = false; // this effect run's own flag
    setState({ data: null, loading: true, error: null });

    fetch(url)
      .then((response) => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return response.json();
      })
      .then((data) => {
        if (!ignore) setState({ data, loading: false, error: null });
      })
      .catch((error) => {
        if (!ignore) setState({ data: null, loading: false, error });
      });

    return () => {
      ignore = true; // a newer run (or unmount) took over
    };
  }, [url]);

  return state;
}
What’s happening
  1. Each effect run has its own ignore variable, captured by its .then callbacks (a closure per run).
  2. When url changes, the cleanup sets the old run's ignore to true. The old request still completes, but its callbacks see ignore === true and do nothing.
  3. Run through the same race test: IGNORE: data = {"id":2} | first request aborted? false. Correct data, but the stale request was not cancelled — it ran to completion.
  4. So: the flag fixes correctness, AbortController fixes correctness and stops the network work. Use the flag for promises that can't be aborted (a third-party SDK call); use AbortController for fetch.
  5. A single component-wide isMounted ref is weaker: it handles unmount but not the URL-change race, because the component is still mounted when the stale response lands.

#useDebounce and useThrottle

Both limit how often something happens when events arrive faster than you want to handle them:

  • Debounce: wait until things go quiet. Only act once no new event has arrived for N ms. Typing "react" into a search box fires one search for "react", not five for "r", "re", "rea", "reac", "react". The mental model is a lift door: every time someone steps in, the door's timer restarts; it only closes once nobody has come for a few seconds.
  • Throttle: at most once every N ms. Keep acting while events continue, but never more often than the interval — scroll position, resize, mouse move, a "save while typing" indicator. The mental model is a metronome: however fast you tap, it ticks at its own pace.

From my notes: the "REACT debouncing…", "Throttling" and "Lodash…Debouncing & Throttling…" sections were headings whose examples were screenshots that didn't come through, so the code here is new. The plain-JavaScript versions of debounce and throttle are in Debouncing and throttling in components; this topic is about the hooks.

useDebounce.js
import { useState, useEffect } from "react";

export function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);

  return debounced;
}

// Trace with delay 300 (value changes at t=0 "a", t=0 "ab", t=200 "abc"):
// t=0 a | t=200 a | t=499 a | t=500 abc
What’s happening
  1. The hook keeps its own state, debounced, which starts equal to value.
  2. Every time value changes, the effect schedules setDebounced(value) in 300 ms. But before the effect re-runs for the next value, React runs the cleanup: clearTimeout(id) cancels the pending timer. That's the lift door restarting.
  3. In the trace, "ab" is scheduled for t=300, but at t=200 "abc" arrives, cancelling it and scheduling t=500. At t=499 the hook still returns "a"; at t=500 the timer fires and it returns "abc". "ab" was never emitted.
  4. The cleanup also runs on unmount, so a pending timer can't fire after the component is gone.
  5. The whole debounce lives in the effect's cleanup — the same pattern as AbortController in useFetch: each run undoes the previous run's pending work.

Using it for search-as-you-type:

Search.jsx
import { useState, useEffect } from "react";
import { useDebounce } from "./useDebounce";

export function Search({ onSearch }) {
  const [text, setText] = useState("");
  const query = useDebounce(text, 300);

  useEffect(() => {
    if (query) onSearch(query);
  }, [query, onSearch]);

  return <input placeholder="Search" value={text} onChange={(e) => setText(e.target.value)} />;
}
What’s happening
  1. text updates on every keystroke, so the input stays responsive — debouncing the input's state would make typing lag.
  2. query is the debounced copy. It only changes after 300 ms without a keystroke.
  3. The effect depends on query, not text, so onSearch runs only when the debounced value changes.
  4. The test typed r, re, rea, reac, react 100 ms apart (with Vitest's fake timers): after the last keystroke onSearch had not been called; 200 ms later (300 ms of quiet) it was called once, with "react".
  5. onSearch is in the dependency array; if the parent passes a new arrow function every render, the effect will re-run on each parent render. Pass a stable function (useCallback) or read it through a ref.

Sometimes you want to debounce an action (autosave, analytics) rather than a value. Then the hook returns a debounced function:

useDebouncedCallback.js
import { useRef, useEffect, useCallback } from "react";

export function useDebouncedCallback(callback, delay = 300) {
  const callbackRef = useRef(callback);
  const timerRef = useRef(null);

  useEffect(() => {
    callbackRef.current = callback; // always call the latest version
  });

  useEffect(() => () => clearTimeout(timerRef.current), []); // cancel on unmount

  return useCallback(
    (...args) => {
      clearTimeout(timerRef.current);
      timerRef.current = setTimeout(() => callbackRef.current(...args), delay);
    },
    [delay]
  );
}

// const save = useDebouncedCallback((text) => api.saveDraft(text), 300);
// save("a"); save("ab");  → after 300 ms: saveDraft("ab") once
What’s happening
  1. The timer ID lives in a ref, so it survives re-renders without causing them. One timer per component instance.
  2. The returned function is wrapped in useCallback([delay]), so it's the same function across renders. If it changed each render, every keystroke would get a fresh timer and nothing would be debounced.
  3. callbackRef always holds the latest callback (updated after every render), so the timer calls the newest version with the newest props — no stale closure, even though the debounced function itself never changes.
  4. In the test, save("a") then save("ab") called the callback once, with "ab". A third call followed by unmount() was cancelled: still one call after the timers ran.
  5. The second useEffect with [] returns a cleanup that clears the timer on unmount — an autosave must not fire against a form that's gone.

A throttle hook, for values like scroll position:

useThrottle.js
import { useState, useEffect, useRef } from "react";

export function useThrottle(value, interval = 500) {
  const [throttled, setThrottled] = useState(value);
  const lastRun = useRef(Date.now());

  useEffect(() => {
    const elapsed = Date.now() - lastRun.current;
    if (elapsed >= interval) {
      lastRun.current = Date.now();
      setThrottled(value);
      return;
    }
    // too soon: schedule a trailing update for the end of the window
    const id = setTimeout(() => {
      lastRun.current = Date.now();
      setThrottled(value);
    }, interval - elapsed);
    return () => clearTimeout(id);
  }, [value, interval]);

  return throttled;
}

// value changes every 100 ms (0, 100, 200, …, 1300), interval 500:
// 100:0 200:0 300:0 400:0 500:400 600:400 … 900:400 1000:900 … 1300:900 end:1300
What’s happening
  1. lastRun (a ref) remembers when the throttled value last changed. It starts at mount time.
  2. When value changes and less than interval has passed, the hook doesn't update now; it schedules a trailing update for the end of the window (interval - elapsed ms away). A newer value cancels and replaces that timer, so the trailing update always carries the latest value.
  3. In the trace, the value changes every 100 ms, but the hook's output changes only at t=500 (to 400, the latest value at that moment) and t=1000 (to 900) — once per 500 ms window.
  4. After the changes stop, the last pending timer delivers 1300. Without the trailing update, a throttle can leave the UI on a stale value after the user stops scrolling.
  5. Compare with debounce: if the value kept changing forever, useDebounce would never update, while useThrottle keeps ticking every 500 ms.

#useLocalStorage

Added

useLocalStorage(key, initialValue) is useState that survives a page reload: it reads the saved value when the component first renders and writes every change back to localStorage. It's a common interview exercise because it touches lazy initialisation, effects, JSON, error handling and cross-tab events in a few lines.

The mental model: useState with a notebook. On startup it reads the notebook once; after every change it writes the new value down. Everything else is handling the cases where the notebook is missing, unreadable, or changed by someone else.

useLocalStorage.js
import { useState, useEffect } from "react";

function read(key, initialValue) {
  try {
    const raw = window.localStorage.getItem(key);
    return raw === null ? initialValue : JSON.parse(raw);
  } catch {
    return initialValue; // bad JSON, or storage blocked (private mode, sandboxed iframe)
  }
}

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => read(key, initialValue));

  // Write every change back
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // quota exceeded or storage blocked: keep working in memory
    }
  }, [key, value]);

  // Another tab changed the same key
  useEffect(() => {
    function onStorage(event) {
      if (event.storageArea === window.localStorage && event.key === key) {
        setValue(event.newValue === null ? initialValue : JSON.parse(event.newValue));
      }
    }
    window.addEventListener("storage", onStorage);
    return () => window.removeEventListener("storage", onStorage);
  }, [key, initialValue]);

  return [value, setValue];
}
What’s happening
  1. useState(() => read(key, initialValue)) uses a lazy initialiser: the function runs on the first render only. Reading storage on every render would be wasted work — the test counted getItem calls: 1 across three renders.
  2. localStorage only stores strings, so values go through JSON.stringify/JSON.parse. That's what lets 16 come back as a number and objects come back as objects.
  3. read wraps everything in try/catch: a corrupted value ("{oops" in the test) or storage that throws (Safari private mode, a sandboxed iframe) falls back to initialValue instead of crashing the page.
  4. The first effect writes after every change of value. Writing in an effect, not inside the setter, means functional updates like setFontSize(s => s + 2) work unchanged — the hook returns React's own setValue.
  5. The storage event fires in other tabs when one tab writes. The second effect listens for our key and updates state, so two open tabs stay in sync.
Settings.jsx
import { useLocalStorage } from "./useLocalStorage";

export function Settings() {
  const [fontSize, setFontSize] = useLocalStorage("fontSize", 16);
  return (
    <>
      <p>Font: {fontSize}px</p>
      <button onClick={() => setFontSize((s) => s + 2)}>Bigger</button>
    </>
  );
}

// Click Bigger twice → "Font: 20px", localStorage "fontSize" = "20"
// Unmount and render a fresh <Settings /> → "Font: 20px"
What’s happening
  1. First render: storage has no fontSize, so getItem returns null and the hook uses 16. The write effect then saves "16".
  2. Two clicks: 16 → 18 → 20. Each change re-renders and the effect writes the new value; storage holds "20".
  3. The test unmounted the component and rendered a new one (the same as reloading the page). The lazy initialiser read "20", parsed it to 20, and the new component showed "Font: 20px" from its first render.
  4. Because the value is read during the first render (not in an effect), there's no flash of the default 16 on reload in a client-rendered app.
AdvancedKeeping several components in the same tab in sync

The storage event only fires in other tabs. Two components in the same tab that use the same key each have their own useState, so they drift apart. In the sandbox, a Control and a Preview both used useLocalStorage("fontSize", 16); after one click the page showed control 18 / preview 16.

The fix is to treat localStorage as an external store and read it with useSyncExternalStore, notifying every subscriber in this tab ourselves:

useStoredValue.js
import { useSyncExternalStore, useCallback } from "react";

const listeners = new Set();

function subscribe(listener) {
  listeners.add(listener);
  window.addEventListener("storage", listener); // other tabs
  return () => {
    listeners.delete(listener);
    window.removeEventListener("storage", listener);
  };
}

export function useStoredValue(key, initialValue) {
  // The snapshot is the raw string: strings compare by value, so it's "cached" for free.
  const raw = useSyncExternalStore(subscribe, () => localStorage.getItem(key), () => null);
  const value = raw === null ? initialValue : JSON.parse(raw);

  const setValue = useCallback(
    (next) => {
      const current = localStorage.getItem(key);
      const prev = current === null ? initialValue : JSON.parse(current);
      const resolved = typeof next === "function" ? next(prev) : next;
      localStorage.setItem(key, JSON.stringify(resolved));
      listeners.forEach((l) => l()); // this tab
    },
    [key, initialValue]
  );

  return [value, setValue];
}

// Same Control + Preview test:  control 18 / preview 18
What’s happening
  1. There's no useState at all — localStorage is the state. Every component reads it through useSyncExternalStore.
  2. The snapshot is the raw string from getItem. Two reads of an unchanged key give equal strings, so React's Object.is check passes without any caching (returning the parsed object would trigger the "getSnapshot should be cached" error).
  3. setValue writes to storage, then calls every listener in a module-level Set. Each subscribed component re-reads its snapshot, and the ones whose string changed re-render: control 18 / preview 18.
  4. The same subscribe also listens to the storage event, so other tabs keep working.
  5. The third argument () => null is the server snapshot, so on the server the hook renders initialValue without touching window.

#How hooks work under the hood

AddedAdvanced

You don't need React's source code to use hooks, but one picture explains every rule in this chapter: each component instance has a linked list of hook records, and React walks it in call order on every render.

Each rendered component instance is represented by a fiber — an object React keeps between renders. The fiber's memoizedState field points to the first hook record; each record holds that hook's state and a next pointer to the following one. While rendering, React keeps a "current hook" cursor that starts at the head and moves one step on every hook call. During the first render (mount), each call appends a record; during later renders (update), each call reads the next one. Nothing identifies a hook except its position in that list.

Here's a toy version that keeps the important part — state stored by position — in 30 lines of plain JavaScript:

mini-hooks.mjs
let hooks = []; // this component's hook list
let index = 0; // which hook we're on during this render
let component; // the function being rendered

function useState(initial) {
  const i = index++; // claim the next slot
  if (hooks.length <= i) hooks[i] = initial; // first render: create the slot
  const setState = (next) => {
    hooks[i] = typeof next === "function" ? next(hooks[i]) : next;
    render(); // re-render (synchronously, in this toy)
  };
  return [hooks[i], setState];
}

function render() {
  index = 0; // every render starts at slot 0
  return component();
}

function mount(fn) {
  component = fn;
  hooks = [];
  return render();
}

let setters;
function Profile() {
  const [name, setName] = useState("Rohit");
  const [likes, setLikes] = useState(0);
  setters = { setName, setLikes };
  console.log(`render: name=${name} likes=${likes}  slots=${JSON.stringify(hooks)}`);
}

mount(Profile);
setters.setLikes((n) => n + 1);
setters.setName("Ada");

// render: name=Rohit likes=0  slots=["Rohit",0]
// render: name=Rohit likes=1  slots=["Rohit",1]
// render: name=Ada likes=1  slots=["Ada",1]
What’s happening
  1. mount(Profile) empties the list and renders. index starts at 0, so useState("Rohit") claims slot 0 and useState(0) claims slot 1: ["Rohit", 0].
  2. Each setState is a closure over its own slot number i. setLikes always writes slot 1, setName always slot 0 — the setter remembers where, not what.
  3. setLikes(n => n + 1) updates slot 1 to 1 and re-renders. index resets to 0; the slots already exist, so the initial values "Rohit" and 0 are ignored and the stored values come back.
  4. setName("Ada") writes slot 0 and re-renders: name=Ada likes=1. Each render reads the same slots in the same order.
  5. Real React differs in the details — a linked list instead of an array, updates queued and batched instead of applied immediately, separate mount and update code paths — but "state is found by call order" is exactly the same.

Now break rule 1 in the toy, with the Profile from Rules of hooks:

mini-hooks-broken.mjs
let showBio = false;
function Profile() {
  const [name] = useState("Rohit");
  if (showBio) {
    const [bio] = useState("Android + React");
    console.log(`render: name=${name} bio=${bio}  slots=${JSON.stringify(hooks)}`);
    return;
  }
  const [likes] = useState(3);
  console.log(`render: name=${name} likes=${likes}  slots=${JSON.stringify(hooks)}`);
}
mount(Profile);
showBio = true;
render();

// render: name=Rohit likes=3  slots=["Rohit",3]
// render: name=Rohit bio=3  slots=["Rohit",3]
What’s happening
  1. The first render fills slot 0 with "Rohit" and slot 1 with 3 (likes).
  2. With showBio = true, the second useState call is the bio one. It gets index 1, finds slot 1 already filled, and returns 3.
  3. bio=3 — the toy reproduces exactly what real React 19.3 rendered for the same component ("Rohit: 3"), because both find state by position.
  4. Real React adds a dev-only check that the kind of hook in each position matches the previous render, which is where the "change in the order of Hooks" warning comes from. It can't catch this case because both are useState.

And here's the real thing — peeking at React 19.3's fiber in the sandbox (private internals, for learning only; never rely on them in app code):

React
function Profile() {
  const [name] = useState("Rohit");
  const [likes] = useState(3);
  const ref = useRef("hello");
  const doubled = useMemo(() => likes * 2, [likes]);
  return <p>{name} {doubled}</p>;
}

// Walk fiber.memoizedState → .next → .next …
// component: Profile | hooks: "Rohit" -> 3 -> {"current":"hello"} -> [6,[3]]
What’s happening
  1. The test found the <p>'s fiber through React's private __reactFiber$… DOM property and stepped to its parent, the Profile fiber.
  2. fiber.memoizedState is the first hook record; following .next visits them in call order.
  3. The two useState records hold "Rohit" and 3. The useRef record holds the ref object { current: "hello" } — which is why a ref survives re-renders: it's just another slot.
  4. The useMemo record holds [6, [3]]: the cached value and the dependencies it was computed with. On the next render React compares the new deps with [3] and reuses 6 if they match.
  5. Every hook, built-in or custom, ends up as records in this one list. A custom hook adds no record of its own — only the built-in hooks it calls do.

Two more pieces complete the picture:

  • The dispatcher. useState imported from react doesn't contain the logic; it forwards to whatever "dispatcher" React has installed. React installs the mount dispatcher while rendering a component for the first time, the update dispatcher on re-renders, and nothing outside rendering. That's why calling a hook from a plain function throws "Invalid hook call" — there's no dispatcher — and why two copies of React cause the same error: the copy you imported from isn't the one rendering.
  • Updates are queued. setState doesn't change the value immediately. It adds an update to that hook's queue and schedules a render; during the next render React processes the queue in order (that's how setCount(c => c + 1) three times gives +3). See State updates are batched and Functional updates and stale closures.

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.