Build a Toy useState to Understand Snapshots, Queues and Effects
Learn how React hooks behave by building a tiny useState, then use that model to explain update queues, stale snapshots, closures, effect cleanup, refs and batching.
This article is published in English.
Sources: React Hooks Internally: How `useState`, `useEffect`& Other Hooks Actually Work
A single line of hook code hides a surprising amount of machinery:
const [count, setCount] = useState(0);
React must decide where count is kept, how it outlives each call, which state a given setter targets, why hook calls cannot be conditional, and how effects track dependencies. This guide builds a small working model of hooks and uses it to explain the behaviors that trip people up daily: stale values after setState, functional updates, effect cleanup and batching.
State does not live in the function
Take the smallest possible stateful component:
function Counter() {
const [count, setCount] = useState(0);
return <h1>{count}</h1>;
}
It is tempting to think count lives inside Counter, but React simply calls the function again on each render:
Render 1
Counter()
count = 0
Render 2
Counter()
count = 1
Render 3
Counter()
count = 2
Local variables vanish when a function returns, so React keeps hook state outside the component, attached to its Fiber, and hands the stored value back on each render.
Fiber holds a sequence of hooks
In simplified form, the relationship looks like this:
Component
│
▼
Fiber
│
▼
Hook data
│
┌──┼───────────────┐
▼ ▼ ▼
State Ref Effect
A Fiber is a unit of React's work, created for components and host elements alike. For a function component it carries the hook data, conceptually as a chain:
Counter Fiber
│
▼
Hook #1
│
▼
Hook #2
│
▼
Hook #3
The real implementation has many more fields, but this picture is enough to reason about the core behavior.
Hooks are matched by call order
Here is a component with three hooks:
function Component() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Salim");
useEffect(() => {
console.log(count);
}, [count]);
return ...;
}
React has no names for these hooks; it only knows first, second and third, and assumes they arrive in that sequence on every render:
Fiber
│
├── Hook #1 → useState
│
├── Hook #2 → useState
│
└── Hook #3 → useEffect
That assumption is the whole basis of the Rules of Hooks.
What goes wrong with a conditional hook
Now consider a hook hidden behind a condition:
function Component({ loggedIn }) {
if (loggedIn) {
useState("Salim"); // ❌
}
useState(0);
return ...;
}
On a render where the user is logged in, two hooks are called:
loggedIn = true
Hook #1 → name
Hook #2 → count
If loggedIn later becomes false, only one call happens:
loggedIn = false
Hook #1 → count
Position one used to hold the name and now receives the count call, so state is paired with the wrong hook:
Before:
1 → name
2 → count
After:
1 → count
So hook order must never vary between renders, which rules out calls in places like these:
if / else
loops
nested functions
event handlers
Keep them at the top level of components and custom hooks; the React hooks lint plugin enforces this.
A toy useState in twenty lines
Building a minimal version makes the mechanism concrete. The sketch below keeps an array of hook slots and a cursor. Each useState call claims the slot at the cursor, initializes it only if empty, returns a setter bound to that index, and advances the cursor:
let hooks = [];
let currentHook = 0;
function useState(initialValue) {
const index = currentHook;
if (hooks[index] === undefined) {
hooks[index] = {
state: initialValue
};
}
function setState(value) {
hooks[index].state =
typeof value === "function"
? value(hooks[index].state)
: value;
render();
}
const state = hooks[index].state;
currentHook++;
return [state, setState];
}
Two things are implied but not shown: render() must reset currentHook to 0 before calling the component again, and it must re-run the component. Given a component like this:
function Counter() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Salim");
return ...;
}
the storage ends up looking like:
hooks[0]
state = 0
hooks[1]
state = "Salim"
On the next render each call finds its slot again purely by position.
Where the toy model stops
Real React adds hook objects on Fibers, update queues, a scheduler with priority lanes, mount and update dispatchers, reconciliation and commit logic; there is no global array. The essential idea still carries over:
Stable Hook order
+
React-managed Hook state
+
Component Fiber
How useState works in React
For this call:
const [count, setCount] = useState(0);
React needs two things per state hook:
State
+
Update queue
In picture form, the hook holds its current value in memoizedState plus a queue of pending updates:
Hook
├── memoizedState → 0
│
└── queue
└── pending updates
The first render: mount
Given this component:
function Counter() {
const [count, setCount] = useState(0);
return <button>{count}</button>;
}
the initial render creates the hook, stores the initial value, sets up an empty queue and returns the pair:
Component renders
↓
useState(0)
↓
Create Hook
↓
Store initial state
↓
Create update queue
↓
Return [0, setCount]
The Fiber now looks roughly like this, and this phase is called mount:
Fiber
│
└── Hook
├── state: 0
└── queue
Later renders: update
Suppose an event handler calls:
setCount(5);
React schedules an update. During the next render, useState does not create anything; it locates the existing hook, applies queued updates and returns the result:
Component renders
↓
useState(0)
↓
React finds existing Hook
↓
Process queued updates
↓
New state = 5
↓
Return [5, setCount]
The 0 you pass is therefore an initial value, not a reset. It matters only when the hook is first created.
setState is a request, not an assignment
A call like this:
setCount(5);
does not mean "set count to 5 now". It enqueues a request that React handles on its own schedule:
setCount(5)
↓
Create update
↓
Add update to queue
↓
Schedule React work
↓
Render component
↓
Process update queue
↓
Calculate new state
↓
Reconcile UI
↓
Commit changes
Updates queue up in order
Consider three functional updates in a row:
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
React keeps them in the queue, starting from the current value:
Update Queue
c => c + 1
c => c + 1
c => c + 1
If the current state is: 0
and applies them one after another:
0 → 1
1 → 2
2 → 3
The result is 3, which is why functional updates suit updates that depend on the previous value.
Why passing a value behaves differently
Compare the version that passes values:
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
If the current render has count equal to 0, all three expressions evaluate before any update is processed, so they amount to:
setCount(1)
setCount(1)
setCount(1)
The final state is 1. With updater functions:
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
each function receives the result of the previous one, giving 0, 1, 2 and finally 3.
State is a snapshot of one render
This handler surprises many developers:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(1);
console.log(count);
}
return <button onClick={handleClick}>{count}</button>;
}
The log prints 0, not 1. count is a constant belonging to the render that created the handler; calling the setter does not change it:
Current render
count = 0
↓
setCount(1)
↓
Update scheduled
↓
Current render still has count = 0
↓
Next render gets count = 1
Closures explain the snapshot
This behavior is plain JavaScript closure semantics. Look at:
function Counter() {
const [count, setCount] = useState(0);
function logCount() {
console.log(count);
}
return <button onClick={logCount}>Log</button>;
}
Every render defines a new logCount, and each one captures the count of its own render:
Render #1
count = 0
└── logCount closes over 0
Render #2
count = 1
└── logCount closes over 1
An old callback always sees its own render's values, the root of most stale-value bugs.
How useEffect remembers its dependencies
Take a basic effect:
useEffect(() => {
console.log(count);
}, [count]);
React must keep track of three things for it:
Effect function
Dependencies
Cleanup function
Internally these are stored on the effect's hook, with names close to these:
Hook
├── create → effect function
├── deps → [count]
└── destroy → cleanup
On each render, React compares the new dependency array with the stored one.
Comparing dependencies
Using the same effect:
useEffect(() => {
console.log(count);
}, [count]);
the first render records:
count = 0
deps = [0]
and the next render produces:
count = 1
deps = [1]
React compares the arrays item by item:
Previous: [0]
New: [1]
Changed
↓
Effect needs to run
Something changed, so the effect runs. If a later render yields the same values:
Previous: [1]
New: [1]
Same
↓
Effect can be skipped
the effect is skipped. Each item is compared with Object.is, which means an object or array recreated during render counts as changed every time.
Cleanup
An effect can return a function that undoes its work:
useEffect(() => {
const id = setInterval(() => {
console.log("tick");
}, 1000);
return () => {
clearInterval(id);
};
}, []);
That returned function is the cleanup. The lifecycle of the interval looks like this:
Effect setup
↓
Interval created
↓
...
Cleanup
↓
Interval removed
When dependencies can change, cleanup matters even more:
useEffect(() => {
subscribe(userId);
return () => {
unsubscribe(userId);
};
}, [userId]);
On every change of userId, React tears down the previous effect before starting the new one, and it runs the final cleanup when the component unmounts:
Old effect
↓
Cleanup
↓
New effect
Effects synchronize, they are not lifecycle methods
A widespread shortcut is to treat useEffect with an empty array as componentDidMount. A more useful framing is that an effect keeps React in sync with some system outside React, such as:
Network requests
Timers
Subscriptions
Browser APIs
DOM APIs
Third-party libraries
WebSocket connections
For instance, a chat connection:
useEffect(() => {
const connection = connect(roomId);
return () => {
connection.disconnect();
};
}, [roomId]);
When roomId changes, the effect leaves the old room and joins the new one, so the connection always matches current props.
useRef: one stable object
A ref is created like this:
const ref = useRef(0);
React returns the very same object on every render:
Render #1
ref → Object A
Render #2
ref → Object A
Render #3
ref → Object A
As a result, assigning to it:
ref.current = 100;
does not cause a render, unlike a state update:
setCount(100);
which schedules one. The contrast in short:
useState
├── React-managed state
├── update schedules render
└── used for UI state
useRef
├── stable object
├── mutable .current
└── changing .current doesn't schedule render
A typical use is storing a timer ID:
function Timer() {
const timerRef = useRef(null);
function start() {
timerRef.current = setInterval(() => {
console.log("tick");
}, 1000);
}
return <button onClick={start}>Start</button>;
}
The ID must persist but never appears on screen, the classic job for a ref.
useMemo and useCallback
A memoized calculation looks like this:
const result = useMemo(
() => expensiveCalculation(data),
[data]
);
The hook conceptually stores the last result and the dependencies used to compute it:
Hook
├── memoizedValue
└── dependencies
On the first render, the calculation runs:
data = A
calculate(A)
↓
result = X
If the next render passes the same data:
data = A
the dependencies are unchanged:
A → A
so the stored result X is returned. When data becomes B, the calculation runs again.
Treat this as an optimization, not a promise: React may discard the cache, so code must stay correct if the value is recomputed. Use it for expensive work or when a stable reference matters, not by default.
useCallback applies the same idea to functions:
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
It stores the function and its dependencies:
Hook
├── memoized function
└── dependencies
Unchanged dependencies return the same function reference; changed ones return a new one. This matters mostly when the callback goes to a child wrapped in React.memo, where a new reference would defeat the memoization:
const Child = React.memo(ChildComponent);
Custom hooks are just functions
Here is a small custom hook:
function useCounter() {
const [count, setCount] = useState(0);
function increment() {
setCount(c => c + 1);
}
return {
count,
increment
};
}
used by a component:
function App() {
const { count, increment } = useCounter();
return (
<button onClick={increment}>
{count}
</button>
);
}
A custom hook is not a separate component. It is a plain function built from other hooks, and its useState attaches to whichever component is currently rendering:
App Fiber
↓
useCounter()
↓
useState()
↓
Hook state
Two components calling useCounter therefore get two independent counters.
Dispatchers: one API, two behaviors
When you call useState, the public function forwards to whatever dispatcher React has installed for the current render:
useState()
↓
Current Dispatcher
↓
mountState() / updateState()
On the first render it is the mount implementation:
Mount
↓
mountState()
and on later renders the update implementation:
Update
↓
updateState()
Mount versus update for one hook
For this call:
const [count, setCount] = useState(0);
mount creates the structures:
Create Hook
↓
Store initial state
↓
Create update queue
while update reuses them:
Find existing Hook
↓
Process pending updates
↓
Calculate new state
↓
Return state + dispatcher
In summary:
FIRST RENDER
↓
Mount Hooks
SUBSEQUENT RENDERS
↓
Update Hooks
Render phase and commit phase
Hooks make most sense against React's two-phase rendering model:
State / Props Update
↓
Render Phase
↓
Reconciliation
↓
Commit Phase
↓
DOM
During render, React calls components and computes the next UI:
Components execute
Hooks are processed
New UI is calculated
During commit, it applies the result:
DOM mutations happen
Refs are updated
Layout effects are handled
Modern React may pause, restart or discard render work, while commit always completes, which is why render logic must be pure.
Keeping render pure
Avoid side effects directly in the component body:
function Component() {
fetch("/api/users"); // ❌
return <div>Hello</div>;
}
Render may run repeatedly or be discarded before commit, so this request could fire many times. Use one of these instead:
Event handler
useEffect
Framework data-loading mechanism
Rendering should only compute UI from inputs:
UI = f(props, state)
Putting it together: one click, end to end
Here is a component using state and an effect:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
);
}
A click travels through every stage described above:
Click
↓
setCount()
↓
Update added to Hook queue
↓
React schedules work
↓
Counter renders
↓
useState retrieves/processes state
↓
useEffect checks dependencies
↓
React reconciles new UI
↓
Commit DOM changes
↓
Effect runs
Batching and why the DOM does not update immediately
A handler that sets several pieces of state:
function handleClick() {
setCount(c => c + 1);
setName("Salim");
setVisible(true);
}
does not cause three separate renders. Since React 18, updates are batched automatically, including those in timeouts and promises:
setCount()
setName()
setVisible()
↓
Batch
↓
Render
↓
Commit
rather than:
Update → Render → Commit
Update → Render → Commit
Update → Render → Commit
Batching is itself a way of skipping unnecessary work.
The same reasoning explains why a call like:
setCount(10);
does not immediately mean:
state = 10
DOM = 10
Instead it flows through the pipeline:
setCount(10)
↓
Schedule update
↓
React render
↓
Reconciliation
↓
Commit
↓
DOM update
Deferring gives React room to batch, prioritize and skip redundant work; flushSync from react-dom forces a synchronous update when truly needed.
The whole model at a glance
Everything above fits into one picture, from the component down to pixels on screen:
FUNCTION COMPONENT
│
▼
Fiber
│
▼
Hook sequence
│
┌───────────────┼───────────────┐
▼ ▼ ▼
useState useRef useEffect
│ │ │
▼ ▼ ▼
Update Queue Stable Object Dependencies
│
▼
setState()
│
▼
Schedule Update
│
▼
Render
│
▼
Reconciliation
│
▼
Commit
│
▼
DOM
│
▼
Browser
│
▼
Pixels
Key takeaways
- Hook state lives on the component's Fiber and is matched to each call by position, which is why hook order must never change.
setStateenqueues an update; the new value appears on the next render, and the current render keeps its snapshot.- Use functional updates whenever an update depends on the previous value.
- Effects synchronize with external systems; dependencies are compared with
Object.is, and cleanup runs before re-running and on unmount. - Refs are stable mutable boxes that never trigger renders; memoization is an optimization, not a guarantee.
- Custom hooks share logic, not state, and render must stay pure because React may repeat or discard it.