REACT: Under the Hood of UseState

Table of contents
- useState Isn’t Just a Variable — It’s a Slot in a List
- What Happens When You Call setCount(...)?
- What if multiple setCount calls happen before React re-renders?
- ⚙️ During the next render…
- ✅ Why Devs Should Care
- 🧬 How React Processes Updates (Behind the Scenes Revisit)
- 🧠 Real-World Mental Model
- 🧵 Just Enough Fiber: How React Knows Which Hook Is Which
- 🔍 Visual Recap: The useState Flow, Start to Finish
- 🧠 TL;DR — How useState Really Works
- ⭐️BONUS: setCount(count + 1) vs setCount(c => c + 1)⚔️
- 🚀 In the End…
- 🧠 TL;DR
- 🤯 Final Thought
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. — soupdate.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:
React reaches
Hook 0
againSees there's a
queue.pending
Walks the circular list and applies all updates in order
Updates
memoizedState
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 aftersetCount(...)
shows stale dataWhy 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:
eachsetCount
adds a node, and React later walks through all of them in order.
⚙️ During the next render…
React performs this:
Starts from
memoizedState = 0
Applies update 1:
c => c + 1
→1
Applies update 2:
c => c + 1
→2
Applies update 3:
c => c + 1
→3
Sets
memoizedState = 3
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:
React walks hook list → starts with Hook 0
Sees updates in queue:
Applies all 3
c => c + 1
updatesFinal value becomes
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.
React sets a pointer to
Fiber.memoizedState
Every time a hook is called, React moves to the
next
hook nodeIt 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 seeingHooks (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:
React walks the hook list via
next
Applies each hook’s queue
Updates
memoizedState
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 ordersetState()
pushes updates into a circular queueOn 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:
0 → 1 → 2 → 3 ✅
🧪 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.
Subscribe to my newsletter
Read articles from Omm Pani directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
