Home / Articles

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.

Publisher: REACTAPP.TOP14 min read3107 words

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.
  • setState enqueues 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.