Skip to content

    Frontend System Design

    How Hooks Work: State, Ordering and the Rules of Hooks

    Learn how React finds hook state by call order, queues updates and batches them into one render, and why the rules of hooks exist, with tests you can run.

    By Published 12 min read

    • frontend-system-design
    • react
    • hooks
    • usestate
    • rules-of-hooks
    Share

    Standard lessonintermediateReact labFrontend System Design

    Before this: React's Render and Commit Phases

    What you'll learn: By the end of this lesson you will be able to explain where React keeps a component's hook state, why a hook is found by its call order and what breaks when that order changes, how a set function queues an update and why one event produces one render, why state belongs to a position in the tree, and how to check each of these claims with tests that run React 19 under jsdom.

    Picture a cloakroom with numbered pegs. You hand over a coat and get peg 1, then a bag and get peg 2. Nobody writes your name down. On your next visit the attendant hands back "whatever is on peg 1" for the first thing you ask for and "peg 2" for the second. It works perfectly as long as you ask in the same order every time. Skip the coat one day and the bag comes back as your coat.

    React hooks work like that cloakroom. A function component is called again on every render, and each call to useState has to get back the value that belongs to it. React has no name to look it up by, only the order of the calls.

    The running example is a product card with a quantity stepper and a gift-wrap checkbox: two pieces of state, quantity and giftWrap. Versions: this lesson describes React 19 (react.dev lists 19.3 as the latest at the time of writing; the lab ran on 19.3.1). The site itself runs React 18.3, and everything here applies to 18 as well.

    The core idea

    1. A hook is found by its position. React keeps an ordered list of hook cells for each component instance. The Nth hook call in a render reads the Nth cell, so the call order must never change between renders.
    2. A set function queues, it does not assign. Calling setQuantity(...) appends an update to that hook's queue and asks React for a render. The variable in the running function does not change. The next render processes the queue.
    3. One render handles everything queued. React waits until the event handler has finished, then renders once with all queued updates applied. That is batching.

    Where hook state lives

    In React's source (ReactFiberHooks.js on the main branch at the time of writing), each hook is an object with the fields memoizedState, baseState, baseQueue, queue and next. The next pointer makes the hooks a linked list on the component's fiber: on a first render mountWorkInProgressHook creates a hook and appends it, and on an update updateWorkInProgressHook walks the previous list in step with your calls and clones each hook. What a fiber is is the subject of Fiber, Scheduling and Concurrent Rendering.

    The name useState is the same on every render, but the implementation behind it is not. renderWithHooks picks one set of hook implementations, a dispatcher, per render: the mount dispatcher when there is no current fiber (or it has no hook state), the update dispatcher otherwise.

    Figure 1. Where a hook call goes

    A hook call goes through a dispatcher to a list of hook cells on the fiberComponent to Dispatcher (useState()); Dispatcher to Hook list (slot N); Set function to Update queue (append update); Update queue to Hook list (applied at next render)REACT RUNTIMEComponent: calls hooks in orderComponentcalls hooks inorderDispatcher: mount or update versionDispatchermount or updateversionHook list: one slot per callHook listone slot per callUpdate queue: one per state hookUpdate queueone per statehookSet function: from a handlerSet functionfrom a handleruseState()slot Nappend updateapplied at next render

    How to read this: your component asks for a hook, the dispatcher answers from the slot at the current position, and set functions never touch the slot directly. They add to a queue that the next render applies.

    Figure 2. The same call on mount and on update

    sequenceDiagram accTitle: useState on mount and on update accDescr: On the first render the mount dispatcher creates a hook cell holding the initial value. On later renders the update dispatcher reads the cell at the same position, applies the queued updates and returns the new value. participant C as Component participant R as React participant H as Hook list C->>R: render 1: useState(1) R->>H: mount: create slot 0 with 1 H-->>C: 1 Note over C,H: a click queues "n => n + 1" on slot 0 C->>R: render 2: useState(1) R->>H: update: read slot 0, apply the queue H-->>C: 2 (the argument 1 is ignored)

    How to read this: the initial value is used once, on mount. Later calls pass the same argument but React ignores it, because slot 0 already exists.

    Illustration 1, minimal. For the product card, the two calls map to two slots:

    Call in the component Slot After render 1 After one click on "+"
    useState(1) for quantity 0 1 2
    useState(false) for giftWrap 1 false false

    The order rule and the rules of hooks

    The React docs state the rules: call hooks only at the top level of a function component or custom hook, "before any early returns", and never "inside loops, conditions, nested functions, or try/catch/finally blocks". They also list event handlers and anything "after a conditional return statement". The older docs give the reason in one line: "React relies on the order in which Hooks are called". If a hook is skipped, every call after it shifts by one.

    React checks the order only by counting. The source throws "Rendered more hooks than during the previous render." from updateWorkInProgressHook when a render asks for a slot that does not exist, and "Rendered fewer hooks than expected. This may be caused by an accidental early return statement." from finishRenderingHooks when slots were left unused. In development it also warns about "a change in the order of Hooks".

    Figure 3. Can I call a hook here?

    flowchart TD accTitle: Can I call a hook here accDescr: A decision tree. Hooks may be called only while a component or custom hook renders, at the top level, before any return, and never inside a condition, loop, nested function, event handler or try block. A{"Inside a component
    or a custom hook?"} -->|"no"| X["Not allowed:
    move the logic into one"] A -->|"yes"| B{"Before every
    early return?"} B -->|"no"| X2["Not allowed:
    move the hook up"] B -->|"yes"| C{"At the top level, not in
    a condition, loop, handler,
    nested function or try?"} C -->|"no"| X3["Not allowed:
    extract a component"] C -->|"yes"| OK["Allowed"]

    How to read this: the rule is about where the call sits, because that decides whether the order can change. The exception is use (React 19), which, unlike hooks, "can be called conditionally".

    Illustration 3, failure: same count, different call site. Counting catches most mistakes, but not all. In the lab, a component calls const [x] = flag ? useState(1) : useState(2): always exactly one call, from two different lines. Rendered with flag true and then false, it showed x=1 both times and React logged nothing: the second render's useState(2) read the slot made by useState(1). The position decided, and the line of code did not. (In development React also tracks hook types, so swapping a useState for a useRef at the same position is a different case.)

    Custom hooks follow from this. A custom hook is an ordinary function that calls hooks, so its hooks simply join the caller's list, in the order it calls them.

    Updates are queued, renders see snapshots

    React's docs call a render a snapshot: "Setting state only changes it for the next render", and "a state variable's value never changes within a render, even if its event handler's code is asynchronous". A handler closes over the values of the render that created it.

    Each state hook has a queue of updates. A plain value adds "replace with" to the queue. A function, called an updater, adds a function of what came before. The docs' worked example queues setNumber(number + 5), setNumber(n => n + 1) and setNumber(42), and processes them in order:

    Queued update Input n Result
    replace with 5 0 (unused) 5
    n => n + 1 5 6
    replace with 42 6 (unused) 42

    The lab reproduces this table with a model of the queue and compares it with React.

    Several more facts follow from the docs:

    • Updaters must be pure. They run during rendering. In Strict Mode, React calls each updater and each state initializer twice and discards the second result (development only).
    • Pass an initializer function, not its result. useState(createInitialItems) calls it once, on mount; useState(createInitialItems()) calls it on every render.
    • Setting an identical value (compared with Object.is) skips the re-render of the component and its children, though "in some cases React may still need to call your component before skipping the children". In the source, dispatchSetStateInternal computes the next state eagerly when the fiber has no pending work, and bails out if it is equal.
    • You may set state during render only for the component that is rendering. React discards that output and renders again at once. Setting another component's state during render is an error.

    Figure 4. One click, three set calls, one render

    sequenceDiagram accTitle: One click handler and one render accDescr: A click handler calls the set function three times. Each call appends to the queue. After the handler returns, React renders once, processes the queue and commits. participant H as Click handler participant Q as Update queue participant R as React participant C as Component H->>Q: setA(n => n + 1) H->>Q: setA(n => n + 1) H->>Q: setA(n => n + 1) H-->>R: handler returns R->>C: render once, apply the queue C-->>R: a = 3 R->>R: commit

    How to read this: nothing renders while the handler runs. The three updaters are applied in order during the single render that follows.

    Batching

    The docs: "React waits until all code in the event handlers has run before processing your state updates." It "makes your React app run much faster". Since React 18, updates in timeouts, promises and native event handlers are batched too ("automatic batching"). When you need the DOM updated earlier, for example to read it, flushSync forces it. Two hooks updated in one handler also cost one render, as the lab's two-hooks-one-render scenario shows.

    Each queued update also carries a lane (a priority), which is how a transition can render later than an urgent update; see the fiber lesson linked above.

    State belongs to a position in the tree

    React's docs say state "is actually held inside React", and React "associates each piece of state with the correct component by where that component sits in the render tree". It keeps state "for as long as you render the same component at the same position". A different key is treated as a different position and resets it. A component defined inside another component is a new component type on every render, so its state is lost each time. In the lab, after two clicks a counter showed count 2, and after the parent re-rendered with new props it showed count 2 (same position), count 0 (new key) and count 0 (nested definition).

    Trade-offs

    Approach Good for Cost
    set(value) One update that does not depend on the previous value Wrong when several calls in one handler build on each other
    set(n => ...) Chained updates; updates from async callbacks The updater must be pure and runs twice in development Strict Mode
    Separate component for the conditional part Keeps hooks at the top level One more component
    key on a subtree Resets all state below it on purpose The subtree is recreated from scratch

    Build it: see the rules run

    Goal. Watch a small model of the hook list and update queue predict what React 19 does, then break the order rule and see what React says.

    Setup. The lab is a TypeScript package in the course's example workspace. It needs Node.js (it was captured on Node 26.12.1) and pnpm. React 19.3.1 and jsdom come from the workspace.

    cd examples/frontend
    pnpm install
    cd labs/react-hooks-internals
    pnpm test            # the model tests and the real-React tests
    node src/demo.ts     # prints the scenarios below

    Steps.

    1. Predict the final value of plain-three (three setA(a + 1) calls in one handler) before reading the output.
    2. Run the demo and compare. Find the log line that shows the whole queue applied in one render.
    3. Read the order, position and Strict Mode sections: the model and React print the same messages, state is kept by position, and an updater runs twice only under Strict Mode.

    Expected output, excerpts captured from a real run (Node 26.12.1, React 19.3.1, jsdom 31.0.0, 2026-11-08):

    === plain-three
    lesson: Three setA(a + 1) calls in one handler all use the same snapshot, so the result is 1, not 3.
    handlers: setA(a + 1); setA(a + 1); setA(a + 1)
      render #1: a=0 b=0
      event 1: handler calls setA(a + 1); setA(a + 1); setA(a + 1)
        setA(a + 1) queues "replace with 1" on slot 0
        setA(a + 1) queues "replace with 1" on slot 0
        setA(a + 1) queues "replace with 1" on slot 0
      slot 0: queue [replace with 1, replace with 1, replace with 1] takes 0 to 1
      render #2: a=1 b=0
    model: a=1 b=0, renders 2
    real React: a=1 b=0, renders 2: identical
    
    [...]
    
    === hook-disappears: renders with flag = true, false
    [...]
    model: Rendered fewer hooks than expected. This may be caused by an accidental early return statement.
    real React: Rendered fewer hooks than expected. This may be caused by an accidental early return statement.: identical
    [...]
    
    === hook-appears: renders with flag = false, true
    [...]
    model: Rendered more hooks than during the previous render.
    real React: Rendered more hooks than during the previous render.: identical
    [...]
    
    ##### 3. State belongs to a position in the tree (real React only)
    click the counter twice, then re-render the parent with new props
    same component, same position:        before "count 2", after "count 2"
    same position, new key:               before "count 2", after "count 0"
    component defined inside the parent:  before "count 2", after "count 0"
    
    ##### 4. Strict Mode calls updater functions twice in development (real React only)
    one setN(updater) with plain root:   updater called 1 time(s)
    one setN(updater) inside StrictMode: updater called 2 time(s)

    The other scenarios and the full text are in examples/frontend/golden/react-hooks-internals.txt.

    jsdom is not a browser. The lab runs React under jsdom, a JavaScript simulation of the DOM. It shows what React does with hook state and updates, nothing about layout, painting or browser scheduling, and it ran on one React version (19.3.1) only.

    Break it. In src/real.ts, in the Gate component, replace if (props.flag) useState(10); with useState(10);. React no longer throws, so the two order comparisons against the model fail. The rule is about the number and order of calls, and the if is only how a mistake usually arrives.

    Stretch.

    • Add a useRef slot (no queue) to the model and test that it survives renders.
    • Model the Object.is bail-out and compare render counts with React, noting where React still calls the component once more.

    In practice: a product card whose data arrives late

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.

    The situation

    Illustration 2, realistic. A product page renders ProductCard immediately with product undefined, then again when the data loads. The card has quantity and gift-wrap state. A developer adds a loading state with an early return, written above the state hooks because it reads naturally:

    function ProductCard({ product }: { product?: Product }) {
      if (!product) return <Skeleton />;            // early return first...
      const [quantity, setQuantity] = useState(1);  // ...hook after it
      // ...
    }

    Starting architecture

    Figure 5. Data arrives after the first render

    Starting architecture: the card renders before its data exists and again when it arrivesProduct page to Product API (fetch); Product API to Product page (product); Product page to ProductCard (props); ProductCard to Error boundary (throws)REACT TREEProduct page: starts the fetchProduct pagestarts the fetchProduct API: responds laterProduct APIresponds laterProductCard: early return, then hooksProductCardearly return,then hooksError boundary: shows a blank fallbackError boundaryshows a blankfallbackfetchproductpropsthrows

    How to read this: the first render calls no hooks, the second calls one, so the card throws on the update and the error boundary replaces it.

    Options considered

    Option Fixes Costs Risk
    Move all hooks above the early return Order is stable Hooks run with product undefined, so the initial values need defaults Easy to forget the default
    Split into ProductCard (loading) and ProductDetails (hooks) Hooks only run when data exists One more component None for the order rule

    The decision

    Field Decision record
    Context An early return placed before a hook changes the number of hook calls between renders.
    Decision Split the component so that ProductDetails is rendered only when product exists, and turn the lint rule on as an error in CI.
    Consequences, good Hooks sit at the top level of a component that always has data. The mistake cannot be repeated unnoticed.
    Consequences, bad One extra component and a prop to pass down.
    Revisit when Loading moves into a data library or Suspense boundary, removing the early return.

    Rollout and verification

    1. Write a test that renders the card with product undefined and then with a product, and asserts it does not throw.
    2. Run the lint rule over the repo and fix every report.

    The 2 a.m. failure

    After a shared custom hook, useCartLine(product), gets a new early return for logged-out users, pages that depend on it go blank for users whose session finishes loading after the first render. The console shows "Rendered more hooks than during the previous render." and the error boundary hides the page.

    First three checks: (1) read the exact message and component name; (2) diff the last release for an early return or condition above a hook; (3) run the hooks lint rule on the changed files. Mitigation: roll back the release. Follow-up: make the lint rule an error in CI and add a "render without data, then with data" test for every component that reads loaded data.

    Architect's notes

    • Make the rules a build-time check. eslint-plugin-react-hooks catches a mistake on the day it is written, not on the day a user hits the unlucky path.
    • Prefer components over conditions. If a hook is needed only sometimes, the part that needs it should be its own component.
    • Treat key as a reset button.

    Common mistakes

    • Calling a hook after an early return or inside an if. The count changes between renders.
    • Expecting setA(a + 1) three times to add three. Each call reads the same snapshot.
    • Defining a component inside another component, which resets its state on every render.

    Key takeaways

    • Hook state is a linked list of cells on the fiber; a hook is found by its position in the call order.
    • React detects a changed hook count (and, in development, a changed type), not a swapped call site with the same type.
    • A set function queues an update; a render always sees one snapshot.
    • Plain values replace and updaters compose; everything queued before a render is handled by one render (batching), in handlers, timeouts and promises since React 18.
    • State belongs to a position in the tree: the same position keeps it, a different key or a nested definition resets it.
    • Updaters and initializers must be pure; Strict Mode calls them twice in development.

    Practice exercises

    Exercise 1, Starter (30 min): Write a counter with three buttons: setN(n + 1) three times, setN(n => n + 1) three times, and setN(5); setN(n => n + 1). Predict each result, then verify with Testing Library.

    Exercise 2, Intermediate (2 hours): Write a component with an early return before a hook. Render it with and without data, catch the error with an error boundary, and fix it by moving the hook and by splitting the component.

    Exercise 3, Advanced (half day): Extend the lab's model with useRef and useReducer slots and compare each with the real React.

    In this course:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments