DEV Community

Cover image for How React Works (Part 6)? How State Actually Works: useState from the Inside
Sam Abaasi
Sam Abaasi

Posted on

How React Works (Part 6)? How State Actually Works: useState from the Inside

How State Actually Works: useState from the Inside

Series: How React Works Under the Hood
Part 1: Motivation Behind React Fiber: Time Slicing & Suspense
Part 2: Why React Had to Build Its Own Execution Engine
Part 3: How React Finds What Actually Changed
Part 4: The Idea That Makes Suspense Possible
Part 5: The React Lifecycle From the Inside
Prerequisites: Read Parts 1–5 first.


Three Things That Confuse Almost Everyone

Here are three behaviors of useState that trip up developers at every level:

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    // You might expect count to become 3
    // It becomes 1
  }

  function handleLog() {
    setCount(count + 1);
    console.log(count);
    // You might expect to see the new value
    // You see the old value
  }

  function handleSame() {
    setCount(count); // setting same value
    // You might expect no re-render
    // Sometimes React still re-renders once
  }
}
Enter fullscreen mode Exit fullscreen mode

All three behaviors make complete sense once you understand how useState actually works under the hood. This article explains all three — and by the end, you'll never be surprised by state again.


Where State Lives

The most important question to start with: when you call useState(0), where does that 0 actually live?

Not in the component function. Component functions are just regular JavaScript functions — they have no persistent memory between calls. Every time React re-renders your component, it calls the function fresh from the top.

State lives on the Fiber. Specifically, on a hook object stored in the fiber's memoizedState field.

Every hook call creates a new hook object and appends it to a linked list hanging off the fiber:

Counter fiber
  └─ memoizedState → hook (useState count)
                       └─ next → hook (useEffect)
                                   └─ next → hook (useRef)
                                               └─ next → null
Enter fullscreen mode Exit fullscreen mode

Each hook object stores the current state value in its own memoizedState field, plus an updateQueue for pending updates, plus a link to the next hook.

This is why the rules of hooks exist — specifically why you can't call hooks conditionally. React doesn't track hooks by name. It tracks them by position in the linked list. Call number 1 is always useState count, call number 2 is always useEffect, and so on. If you skip a hook call with an if statement, every subsequent hook gets the wrong state from the wrong position.

fiber memoizedState linked list — hook objects in order


What Happens When You Call setState

Here's what most developers picture when they call setCount(count + 1):

React updates the count, then re-renders the component.

Here's what actually happens:

React creates a small update object and adds it to a queue. Then it schedules a re-render for later. The component function does not run again right now.

That's it. Calling setState is not synchronous. It's not instant. It's closer to leaving a note — "please re-render this component with this new value when you get a chance."

From jser.dev's useState article, here is the actual sequence:

Step 1 — Create an update object. React packages your new value (or updater function) into a small object that also carries the current priority lane.

Step 2 — Stash it in a queue. The update goes into a global buffer (concurrentQueues). It's not attached to the fiber yet — React is potentially in the middle of rendering something else and can't safely modify the fiber tree right now.

Step 3 — Schedule a re-render. React calls scheduleUpdateOnFiber, which walks up to the root, marks the trail of childLanes, and registers a task with the Scheduler (from Part 2). The Scheduler will call back to run the render.

Step 4 — At the start of the next render, attach updates. Before the render begins, finishQueueingConcurrentUpdates runs and attaches all stashed updates from the global buffer to their respective fibers' queues. Now the fiber is ready to process them.

Step 5 — During render, process the queue. When React processes the Counter fiber, it reads hook.queue, processes the pending updates in order, and the result becomes the new hook.memoizedState — the new value that useState will return for this render.

setState journey — update object → concurrentQueues → scheduleUpdateOnFiber → Scheduler → render → process queue → new state

A Concrete Trace: One Button Click

Let's make this real. The Counter from the opening — count starts at 0, user clicks the button:

The click happens. The browser fires a click event. React's synthetic event handler calls your handleClick, which calls setCount(count + 1) — that's setCount(1).

dispatchSetState runs. React creates an update object { action: 1, lane: SyncLane } and pushes it into the global concurrentQueues buffer. The Counter fiber is unchanged — it still has hook.memoizedState = 0. The component function has not run again yet.

scheduleUpdateOnFiber runs. React walks up from the Counter fiber to the root, marking childLanes on every ancestor. The Scheduler registers a task to call performConcurrentWorkOnRoot.

The event handler finishes. React checks for other queued updates — there are none. Since this was a user interaction (SyncLane), React runs the render synchronously rather than waiting for the next macro task.

finishQueueingConcurrentUpdates runs. The pending update is moved from the global buffer and attached to hook.queue.pending on the Counter fiber. The fiber is now ready.

React renders the Counter fiber. updateState reads hook.queue, finds the pending update { action: 1 }, runs it through basicStateReducer, and gets 1. This becomes the new hook.memoizedState. The component function runs with count = 1 and returns new JSX.

Commit phase. React finds the changed text node and updates button.textContent to "click 1". The DOM reflects the new state.

Total time from click to DOM update: a few milliseconds. Total re-renders: exactly one.


Why You See the Old Value After setState

Now the first confusion makes sense.

setCount(count + 1);
console.log(count); // still the old value
Enter fullscreen mode Exit fullscreen mode

Calling setCount didn't change count. It created an update object and scheduled a re-render. The variable count is a local variable in the current function call — it was captured when the component rendered and it will never change during this render. The new value only exists after the next render completes and useState reads from the processed queue.

This is a fundamental property of React's model: state values are snapshots. Every render captures its own version of state, and that snapshot is frozen for the duration of that render.


Why Three setCount Calls Only Increment Once

setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// count goes from 0 to 1, not 0 to 3
Enter fullscreen mode Exit fullscreen mode

Each of these three calls captures the same count — the snapshot from the current render, which is 0. So all three are equivalent to setCount(0 + 1). React queues three updates, all with value 1. When it processes them in the next render, the result is 1.

The fix is the updater function form:

setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
// count goes from 0 to 3
Enter fullscreen mode Exit fullscreen mode

When you pass a function, React processes the updates in sequence — each one receives the result of the previous. The queue runs 0 → 1 → 2 → 3. The updater function form is specifically designed for when you need to chain updates.


Why Multiple setState Calls Don't Cause Multiple Re-renders

This is batching — and it's one of React's most important performance features.

When you call setState multiple times in the same event handler, React doesn't re-render after each call. It collects all the updates, then does a single re-render at the end.

function handleClick() {
  setCount(c => c + 1);  // queued
  setName('Alice');       // queued
  setLoading(false);      // queued
  // → one re-render, not three
}
Enter fullscreen mode Exit fullscreen mode

From jser.dev's useState article: updates are stashed in the global concurrentQueues buffer and only attached to fibers at the beginning of the next render via finishQueueingConcurrentUpdates. This means all three setState calls during the same event handler land in the queue before any render begins — React processes them all in one pass.

Before React 18, batching only happened inside React event handlers. In setTimeout, fetch callbacks, or native event handlers, each setState would trigger a separate re-render. React 18 introduced automatic batching — batching now happens everywhere by default, regardless of where the update originates.


The Same-Value Optimization (and Its Catch)

What happens when you call setState with the same value the state already has?

setCount(count); // count is already 5, setting it to 5 again
Enter fullscreen mode Exit fullscreen mode

React tries to skip the re-render entirely. From jser.dev's article: before scheduling the re-render, dispatchSetState eagerly computes the new state and compares it to the current state using Object.is. If they're identical, React calls enqueueConcurrentHookUpdateAndEagerlyBailout and returns immediately — no render is scheduled.

But here's the catch from jser.dev: this bailout only happens when the fiber's update queue is currently empty. If there's other pending work on the component, React can't safely apply this optimization and may still re-render once. The comment in the React source code says "React tries to avoid scheduling re-render with best effort, but no guarantee."

So the rule in practice: setting state to the same value usually prevents a re-render, but not always. Don't write code that depends on this optimization for correctness.


useRef: State Without Re-renders

Now that you understand how useState works, useRef becomes obvious.

useRef is implemented almost identically to useState — it creates a hook object on the fiber's linked list, stores its value in memoizedState. The difference: updating a ref doesn't create an update object and doesn't call scheduleUpdateOnFiber. It just mutates the current property of the ref object directly.

const ref = useRef(0);
ref.current = 1; // direct mutation — no queue, no schedule, no re-render
Enter fullscreen mode Exit fullscreen mode

Because React never learns about the change, it never re-renders. The new value is available immediately (no snapshot problem), but React won't react to it. This makes useRef perfect for values that need to persist across renders without triggering them — timer IDs, previous values, DOM node references.

The tradeoff is exactly what you'd expect: refs are live, mutable, immediate — but invisible to React's rendering system.


The Full Picture

useState is not a magical React primitive. It's a hook object on a linked list on a fiber, with a queue for pending updates and a scheduler that processes them. Every behavior that seems surprising becomes logical once you see the structure:

The snapshot problem exists because state is stored on the fiber, not in a mutable variable — and the fiber gets its new value only after the render processes the queue. The batching behavior exists because updates go into a global buffer before being attached to fibers. The same-value optimization exists because React checks eagerly before scheduling. And useRef is simply the same structure without the scheduling step.

State in React is not instant, synchronous, or mutable in the traditional sense. It's a description of what the next render should look like — and React processes that description on its own schedule, in its own order, using the Fiber and Scheduler machinery from Parts 2 and 3.


What's Coming in Part 7

In Part 7 we look at performance — not the solutions first, but the problems. What actually causes unnecessary re-renders? Why does passing a new object as a prop matter? When is React doing work it doesn't need to? Understanding the problems clearly is what makes React.memo, useMemo, and useCallback make sense — rather than things you add until the lag goes away.


🎬 Watch These

JSer (jser.dev) — How does useState() work internally in React?
The primary source for this entire article — mountState, dispatchSetState, concurrentQueues, finishQueueingConcurrentUpdates, the eager bailout, and the batching mechanism. All sourced from here.

JSer (jser.dev) — How does React re-render internally?
How the update queue is processed during the render phase — the other half of the state story.


🙏 Sources & Thanks

  • jser.dev — every mechanism in this article comes from JSer's source-level analysis of useState. The mountState flow, dispatchSetState internals, concurrentQueues global buffer, finishQueueingConcurrentUpdates, the eager same-value bailout with the "best effort, no guarantee" caveat, and the batching explanation all come directly from How does useState() work internally in React?

  • React sourcepackages/react-reconciler/src/ReactFiberHooks.js (mountState, dispatchSetState, mountWorkInProgressHook) and packages/react-reconciler/src/ReactFiberConcurrentUpdates.js (enqueueConcurrentHookUpdate, finishQueueingConcurrentUpdates) in the facebook/react repo.

  • Lydia Hallie — for JavaScript visualizations that shaped this series' style.


Part 7 is next — performance: what actually causes unnecessary re-renders, before we reach for any optimization tools. 🔧


Tags: #react #javascript #webdev #tutorial

Top comments (0)