useState Under the Hood: How React Stores State in a Linked List

useState is not magic. React stores every hook call in a linked list on the fiber node, and that is why the rules of hooks are not arbitrary — they are load-bearing. Here is exactly how it works.

June 7, 20264 min read1 / 3

In vanilla JavaScript, when a value changes on screen, you grab the DOM element and update its content directly. That works fine outside a framework. In React, that model breaks immediately.

React controls your entire UI. When data changes, React decides when and how to reflect that in the DOM — not you. If you reach into the DOM yourself inside a React component, you step outside React's awareness. You create a split between what React thinks is rendered and what is actually on the screen.

State is the solution. A state is a variable that, when changed, automatically tells React to re-render the component with the new value. You change the data. React updates the UI. That is the entire contract.

The Counter That Explains Everything

The cleanest way to see why state exists is a counter — a number on screen that increases every time you click a button.

Before state, the naive approach using a plain variable:

TSX
let count = 0; function increaseCount() { count += 1; console.log(count); // 1, 2, 3 — correctly increases }

The variable changes. The console confirms it. Nothing on the screen updates.

React never re-rendered because it never knew count changed. There was no signal.

useState is that signal:

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

This creates a state variable (count) initialized to 0, and a setter function (setCount). Every call to setCount tells React to schedule a re-render with the updated value. The UI stays in sync automatically.

Why You Cannot Mutate State Directly

Even though count looks like a normal variable, assigning to it directly does nothing:

TSX
count = count + 1; // no re-render, UI stays frozen

React knows state changed only through the setter. Direct assignment bypasses that completely.

The setter is the contract between your data and React's rendering engine. Skip it, and React never finds out.

Functional Updates: The Safe Way to Depend on Previous State

The setter accepts either a new value directly, or a callback that receives the previous state:

TSX
setCount(prev => prev + 1);

Always use the callback form when the new value depends on the previous one. Here is why it matters.

If you call setCount(count + 1) twice in the same event handler, both calls read the same snapshot of count — they see the same number, produce the same result, and you end up with one increment instead of two. With prev => prev + 1, each call receives the actual latest value and chains correctly.

Two setter calls. Two increments. The direct form gives you one.

State Updates Are Not Instantaneous

This surprises almost everyone the first time:

TSX
function increaseCount() { setCount(prev => prev + 1); console.log(count); // still shows the OLD value }

The console always lags one step behind the setter call. That is intentional.

setCount schedules a re-render — it does not mutate the variable in place. The new value only becomes visible after React finishes that re-render. The count you read anywhere inside a given render is the value that existed for that render.

The next render gets the next value. This render sees this render's value.

What useState Accepts as Initial Value

The initializer can be anything:

TSX
useState(0) // number useState('') // string useState(null) // null / loading state useState([]) // array useState({}) // object

When computing the initial value is expensive — reading localStorage, parsing JSON, running a calculation — pass a function instead of the value directly:

TSX
const [data, setData] = useState(() => JSON.parse(localStorage.getItem('data') ?? '[]'));

The function runs once, on mount. Passing the value directly would re-run that computation on every single render.

The Rules of Hooks — and Why They Exist

Two rules govern all hooks, including useState:

Only call hooks at the top level. Never inside loops, conditions, or nested functions.

Only call hooks from React function components (or custom hooks).

These rules are not arbitrary style preferences. React tracks every hook call in a linked list on the component's fiber node. The order of calls must be identical across every render — that is how React knows which state belongs to which useState call. Put a useState inside an if block and the order changes conditionally, breaking the list.

The rules of hooks are load-bearing. The linter enforces them because violating them produces bugs that are nearly impossible to trace.

Practice what you just read.

Predict the CountDirect vs Functional UpdateFix the Triple Increment
3 exercises