REACT: Under the Hood of UseState

Omm PaniOmm Pani
12 min read

Did you know? React doesn’t store state in variables.

When you call:

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

You might assume React stores count in some internal object or variable tied to your component.

But that’s not how it works.

✅ React actually tracks state using:

  • A linked list of hook nodes

  • A circular queue of updates

  • And a strict render-time hook call order

It’s not magic — it’s just clever bookkeeping.

Let’s lift the hood and walk through how useState works — with real examples

useState Isn’t Just a Variable — It’s a Slot in a List

const [count, setCount] = useState(0); //slot 0
const [text, useText] = useState(""); //slot 1

At first glance, this looks like a variable initialized with 0.
But in React land, this means:

“Hey React, on this render, I want the state stored at slot #0 in this component’s hook list.”

Yes — slot 0. Not a name, not an ID — just a position in a list. Like this:

So how does useState(0) really work?

Here’s what React does behind the scenes 👇

🔢 React assigns a slot based on the order of hook calls
🔗 It stores the value 0 in a linked list of hooks(per component)

Component Fiber
┌────────────────────────────┐
│ hooks (linked list)        │
│                            │
│ Hook 0 ─▶ Hook 1 ─▶ null   │
└────────────────────────────┘

Each hook — whether it’s useState, useReducer, or others — becomes a node in a linked list attached to the component’s Fiber node.

Why a list and not an array? Because:

  • Hook count can vary

  • Lists allow predictable traversal

  • And React re-creates this list fresh on every render, based on call order

😱 Why You Can’t Call Hooks Conditionally

Ever seen this?

⚠️ “Rendered fewer hooks than expected.”

That’s React telling you:

“Hey, your hook list is broken. I can’t align state to the right slots anymore.”

Example:

if (isLoggedIn) {
  useState("hi"); // ❌ DON'T
}

This messes up the hook slots. On next render, useState("hi") might become slot 0 instead of slot 1.
Now every hook is getting the wrong state.

🔥 TL;DR: Hook call order = sacred. Never call hooks conditionally.

🧠 Real-World Takeaway

This explains bugs like:

  • Stale state

  • Hooks acting "out of sync"

  • setState updating the wrong value (or nothing at all)

React doesn’t remember variable names — it relies entirely on hook call order during render.


What Happens When You Call setCount(...)?

So far, we’ve seen how useState assigns a slot in a linked list to hold your state.

But what happens when you actually update that state?

setCount(c => c + 1);

You might expect the state to update immediately — but React doesn’t do that. Instead, it pushes the update into a queue, and schedules a re-render.

📦 When setCount(newValue) is called:

🔁 React schedules a re-render
🧠 During render, it goes back to the same slot (slot 0)
👉 Retrieves the new updated state
🧩 Then it compares 🔄 the old and new virtual DOM
🎨 If there’s a change, React updates the UI

This flow is what powers React’s magic — hooks + virtual DOM + Fiber working together.

🔁 React uses a circular linked list of updates

Each hook node (like Hook 0 for count) has an internal queue that holds pending updates.

Each hook is a node in a linked list 🔗
Here’s what each node holds:
🧠 memoizedState → the actual state value
📥 queue → list of pending setState calls
⏭️ next → pointer to the next hook in this component
This structure is rebuilt on every render — in the same order!

Let’s visualize it.

Hook 0
┌──────────────────────────────┐
 memoizedState:                0
 queue.pending:                null
 next:                          Hook 1
└──────────────────────────────┘

Hook 1
┌──────────────────────────────┐
 memoizedState:                ""
 queue.pending:                null
 next:                          null
└──────────────────────────────┘

Now you call:

setCount(c => c + 1);

📬 React pushes an update into the queue

Hook 0
┌──────────────────────────────┐
│ memoizedState                │ 0
│ queue.pending                │ ●─────────────┐
│ next                         │               ↓
└──────────────────────────────┘         ┌────────────────────┐
                                         │ action: f (c + 1)  │
                                         │ next ──────────────┘ (points to self)
                                         └────────────────────┘

Hook 1 (unchanged)
┌──────────────────────────────┐
│ memoizedState                │ ""
│ queue.pending                │ null
│ next                         │ null
└──────────────────────────────┘

🌀 React uses a circular list here — Because there’s only one update, next points to itself forming a circular list with one item. — so update.next points to itself if it's the only item.

Why circular? Because it's fast to:

  • Add new updates

  • Walk the queue in order

  • Wrap around without null-checks


✅ What Happens on Re-render?

During the next render cycle:

  1. React reaches Hook 0 again

  2. Sees there's a queue.pending

  3. Walks the circular list and applies all updates in order

  4. Updates memoizedState

  5. Clears the queue

Example:

// Initial state
memoizedState = 0

// apply setCount(c => c + 1)
memoizedState = 1

// clear queue
queue.pending = null

Boom — count becomes 1 in the next render. 🧠

💡 Real-World Takeaway

  • State updates are async because React batches them in queues

  • setState doesn’t update immediately — it just pushes to the queue

  • You can have multiple queued updates before the next render

This explains:

  • Why console.log(count) right after setCount(...) shows stale data

  • Why batching works (like in event handlers or startTransition)

  • Why setState inside loops can behave unexpectedly


What if multiple setCount calls happen before React re-renders?

Let’s say you run this in an event handler:

setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);

These don’t immediately re-render. Instead, React queues them up in a circular linked list inside Hook 0:

Hook 0 (count)
┌────────────────────────────┐
│ memoizedState: 0           │
│ queue.pending              │ ●────────────┐
└────────────────────────────┘              ↓
       ▲                             ┌─────────────┐
       └────────────────────────────▶ update 1     │
                                     │ action: f   │
                                     │ next ───────┼▶ update 2
                                     └─────────────┘
                                               ↓
                                     ┌─────────────┐
                                     │ update 2    │
                                     │ action: f   │
                                     │ next ───────┼▶ update 3
                                     └─────────────┘
                                               ↓
                                     ┌─────────────┐
                                     │ update 3    │
                                     │ action: f   │
                                     │ next ───────┘ → points to update 1
                                     └─────────────┘  (last update links back to first)
       ▲                                 │      
       └─────────────────────────────────┘

🔄 Updates are queued as a circular list:
each setCount adds a node, and React later walks through all of them in order.


⚙️ During the next render…

React performs this:

  1. Starts from memoizedState = 0

  2. Applies update 1: c => c + 11

  3. Applies update 2: c => c + 12

  4. Applies update 3: c => c + 13

  5. Sets memoizedState = 3

  6. Clears the queue.

🎯 Final count value will be 3, not 1 — because all updates used the latest state (c => c + 1).


✅ Why Devs Should Care

  • setCount(x + 1) (non-functional) would overwrite and only apply once.

  • setCount(c => c + 1) stacks — each update gets applied.

  • You don’t need useEffect() tricks for batching; React’s queue handles this.

  • React batches updates and avoids redundant renders efficiently using this structure.

  • If you queue multiple updates using functional form (c => c + 1), React handles them correctly.

  • If you use non-functional form (setCount(count + 1)), they may overwrite each other. ⚠️


🧬 How React Processes Updates (Behind the Scenes Revisit)

When a re-render is triggered (due to setState), React walks the list of hooks in the exact same order as before.

Each hook looks like this internally:

HookNode {
  memoizedState: current state value,
  queue: {
    pending: circular linked list of updates
  },
  next: → next HookNode
}

Let’s revisit this example:

const [count, setCount] = useState(0); // slot 0
const [text, setText] = useState("");  // slot 1

React builds a linked list of hooks for this component:

┌──────────────┐     ┌──────────────┐
│ Hook 0       │ ──▶ │ Hook 1       │ ──▶ null
│ count = 0    │     │ text = ""    │
└──────────────┘     └──────────────┘

Let’s say setCount(c => c + 1) was called 3 times.

During the next render:

  1. React walks hook list → starts with Hook 0

  2. Sees updates in queue:

    • Applies all 3 c => c + 1 updates

    • Final value becomes 3

  3. Moves to Hook 1 → No updates → Skips.

Hook 0 (updated)
┌────────────────────┐
│ memoizedState: 3   │
│ queue.pending: null│
└────────────────────┘

Hook 1 (unchanged)
┌────────────────────┐
│ memoizedState: ""  │
│ queue: null        │
└────────────────────┘

🧠 Real-World Mental Model

React doesn’t "remember" state via variable names.
It tracks state slot-by-slot via hook order. Hook 0 will always be count.

Changing the order of hooks between renders?
💥 Boom — React throws:
"Rendered fewer hooks than expected..."


🧵 Just Enough Fiber: How React Knows Which Hook Is Which

React's secret sauce? A data structure called Fiber.

Each component gets its own Fiber Node — a JavaScript object that holds everything React needs to track during rendering.

Here’s what a simplified Fiber looks like:

FiberNode {
  memoizedState → points to head of hook list
  return → parent fiber
  child → first child
  sibling → next sibling
  ...
}

🎯 memoizedState is the key.

React stores the head of the hook linked list on the component's Fiber.

As your component renders, React walks that list one hook at a time — in order.


📦 During First Render:

For our example:

const [count, setCount] = useState(0); // slot 0
const [text, setText] = useState("");  // slot 1

React does:

Fiber.memoizedState ──▶ Hook 0 ──▶ Hook 1 ──▶ null
                       count=0    text=""

Each useState() call creates a Hook node and links it to the previous one.

🔁 During Re-render:

Same order. No surprises.

  1. React sets a pointer to Fiber.memoizedState

  2. Every time a hook is called, React moves to the next hook node

  3. It retrieves memoizedState, applies any updates, and moves on

Fiber.memoizedState ─▶ Hook 0 ─▶ Hook 1
(current hook pointer walks one by one)

💡 That’s why hook order must be consistent across renders. If it changes, React can't match the state correctly.


⚙️ Real-World Framing

  • Want to build custom hooks? Now you know they're just functions called in order.

  • Using React DevTools and seeing Hooks (count, text)? That’s this list.

  • Got a bug with mismatched state? Might be breaking hook order internally.


🔍 Visual Recap: The useState Flow, Start to Finish

Let's put the whole story together with ASCII diagrams:

🧱 Initial Render:

const [count, setCount] = useState(0); // slot 0
const [text, setText] = useState("");  // slot 1

React:

Fiber.memoizedState
      │
      ▼
┌───────────────┐     ┌───────────────┐
│ Hook 0        │───▶│ Hook 1        │──▶ null
│ state: 0      │     │ state: ""     │
│ queue: null   │     │ queue: null   │
└───────────────┘     └───────────────┘

⏩ After setCount(c => c + 1):

Hook 0
┌──────────────────────────────┐
│ memoizedState                │ 0
│ queue.pending                │ ●─────────────┐
│ next ───────────────────────▶│               ↓
└──────────────────────────────┘         ┌────────────────────┐
                                         │ update.action: f   │
                                         │ update.next ───────┘ (points to self)
                                         └────────────────────┘

Hook 1 (unchanged)
┌────────────────────┐
│ memoizedState: ""  │
│ queue: null        │
│ next: null         │
└────────────────────┘

🔁 During Re-render:

  1. React walks the hook list via next

  2. Applies each hook’s queue

  3. Updates memoizedState

  4. Resets queue to null

Hook 0
┌──────────────────────────────┐
│ memoizedState                │ 1 ✅
│ queue.pending                │ null
└──────────────────────────────┘

Hook 1 stays unchanged.

🧠 TL;DR — How useState Really Works

  • Hooks form a linked list stored on the component’s Fiber

  • useState() allocates a slot based on call order

  • setState() pushes updates into a circular queue

  • On re-render, React:

    • Walks the hook list

    • Applies all pending updates

    • Updates memoizedState

  • Fiber’s memoizedState → head of the hook list

  • 💥 Hook order must never change


⭐️BONUS: setCount(count + 1) vs setCount(c => c + 1)⚔️

Let’s say count = 0, and you write:

setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

You might expect count to become 3, but instead… it becomes 1.

❗ Why?

count + 1 is evaluated immediately, not lazily.

So each line becomes:

jsCopyEditsetCount(1); // because count is still 0
setCount(1);
setCount(1);

Only the last one wins.


✅ Instead, use the functional form:

setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);

Each call receives the latest state, even if queued.

So:

0123

🧪 Real-World Example

In a counter button:

jsCopyEditonClick={() => {
  setCount(count + 1); // ❌ always sets to 1 if clicked fast
}}

onClick={() => {
  setCount(c => c + 1); // ✅ properly increments
}}

💡 Takeaway

  • Use setCount(prev => prev + 1) when the next state depends on the previous.

  • Especially important when:

    • Scheduling multiple updates

    • React batches them

    • Working with async effects


🚀 In the End…

Why Should You Care?

This wasn’t just a nerdy dive into React internals — it has real-world impact:

Fix bugs you didn’t know existed
Understanding that setState is async and batched helps you avoid subtle bugs in counters, toggles, and complex UI flows.

🔁 Predict re-renders confidently
Knowing how hooks are matched by order and updates are queued means you can refactor without fear.

🎯 Write faster, safer components
Use functional updates where needed. Avoid unnecessary state. Debug smarter.


🧠 TL;DR

React doesn’t “store” state in variables.

It:

  • 🔗 Uses a linked list of hooks per component.

  • 📍 Assigns each useState a slot based on order.

  • 🔄 Queues updates via a circular linked list.

  • 🧮 Applies updates during re-render using the Fiber architecture.

const [count, setCount] = useState(0); // slot 0
const [text, setText] = useState("");  // slot 1

Behind the scenes:

  • count = hook 0 → memoized state = 0

  • setCount(x) → pushes an update to hook 0's queue

  • During next render: React runs reducer logic, gets the new state

  • Updates memoized state, clears queue


🤯 Final Thought

React’s useState isn’t just a simple getter/setter — it’s a finely tuned dance of linked lists, update queues, and render orchestration.

Next time you write setCount(c => c + 1) — know that somewhere deep in Fiber, a tiny linked list is hard at work.


0
Subscribe to my newsletter

Read articles from Omm Pani directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Omm Pani
Omm Pani