React Hooks

A tutorial on how React hooks actually work, read out of React 19.1.0's own source and checked against a real browser. Ten short components with the hook list walked in front of you — what useState stores and where, why the order rule has no exceptions, React's real error for a hook inside an if, why a mutated array changes the data and not the screen, what Object.is decides, and a stale closure built step by step inside a custom hook and then fixed three ways — plus a captured run where three intervals print fifteen zeros, one, and fifteen.

A frontend app built by Ananda Rizki. More of them at Labs.

React hooks, as a mechanism

A list of slots, walked in order, and one comparison that decides everything.

Hooks are strange for one reason, and it is not React's fault.

A function forgets everything when it returns. A component has to remember things. Those two sentences are in direct conflict, and every odd rule about hooks — the order, the dependency arrays, the state that does not change when you set it — is a consequence of how React resolves it. This page assumes nothing: not the words, not React, not the machinery. It starts at what a render is.

If you have read the React Fiber tutorial on this site, you already know the object all of this hangs off. This is one field on it.

FOUR THINGS THAT ARE TRUE BEFORE REACT IS INVOLVED

01

A component is a function, and React calls it

function Profile() { … } is not a thing on the screen. It is a function React calls to find out what should be on the screen, and it calls it again every time something might have changed. One call is a render. A component that has been on screen for a minute may have rendered four times or four hundred, and nothing about the code says which.
02

Each call gets its own variables, and they are gone when it returns

That is not a React rule, it is how functions work. const [count] = useState(0) creates a const called count that belongs to this call. The next render is a different call with a different count, and the previous one's variables are as gone as any other finished function's.
03

So the component cannot remember anything by itself

Write let count = 0 at the top of a component and it will be 0 on every render, forever, because the line runs again every time. Somewhere outside the function has to hold the value between calls, and hand it back at the start of the next one. Every state library ever written is an answer to that sentence.
04

And a function can outlive the call that made it

() => console.log(count) created inside a render keeps working after that render has returned, and it still sees that render's count. A function plus the variables it captured is called a closure. This is normally exactly what you want; it becomes the hardest bug on this page when something holds on to one for longer than you meant.

None of that is specific to React. It is true of any language with closures, and a reader who is comfortable with those four sentences will find the rest of this page mostly mechanical.

FOUR THINGS THAT SURPRISE EVERYONE ONCE

State is a value, not a box

useState does not give you a variable to write to. It gives you this render's value and a way to ask for a different one next render. count does not change when you call setCount; a new render happens, and in that render count is a different const.

Setting state does not stop the function

setCount(1) schedules work and returns. The rest of your handler runs with the old count, because the old count is a const in a call that has not finished. Nothing about the current render changes, ever.

React compares with Object.is, and nothing else

Not a deep comparison, not a check of what you changed — one reference comparison against the value you handed over. That is the whole reason mutating state does nothing visible, and the reason a dependency array behaves the way it does.

A custom hook is a plain function

useSomething has no lifetime, no instance and no state of its own. Its hook calls become slots on the calling component's list, in the order the call appears. That is what makes them composable, and it is what makes a bug inside one so hard to see from outside it.

Each of those four has a lesson of its own further down, with the mechanism traced out and the behaviour captured from a real React 19.1.0 render rather than asserted.

THREE WAYS TO GIVE A FUNCTION A MEMORY — AND WHY REACT USES THE THIRD

01

Put it on the instance

What class components did. this.state, this.setState, and the component is an object that exists between renders, so there is somewhere obvious to keep things.

The state is tied to the class, so sharing stateful logic between two components means a wrapper component around one of them — higher-order components, or render props. Both of those add a layer to the tree for something that is not a layer, and the nesting was bad enough to have its own name.

Obvious storage, and no way to share behaviour without changing the shape of the tree.

02

Pass a bag in

Make the component take its state as an argument: Profile(props, state). Then there is no magic at all, and a hook is just a function that reads the bag.

Someone has to thread the bag through every component and every custom hook, by hand, forever. It also makes every helper that wants state take an extra parameter, which is the same tax in a different place — and the moment a helper forgets to pass it on, the state is silently lost.

Completely explicit, and unbearable at the size of a real application.

03

Keep a list, and use the call order as the name

React keeps a list of slots per component instance. The first hook call in a render gets slot one, the second gets slot two, and on the next render the same calls in the same order line up with the same slots. Nothing is passed in and nothing is named.

The order has to be identical on every render — no hooks in an if, in a loop of changing length, or after an early return — and breaking that rule does not fail politely. This page is mostly about what it does instead.

No wrapper components, no threading, no names. In exchange, one rule that is total. This is what React does.

SO THE WHOLE SUBJECT IS ONE SUBSTITUTION

React cannot ask which piece of state is this, because your code never says. So it answers a different question — which call is this, counting from the start of the render — and treats the second as if it were the first. The two agree exactly as long as you call the same hooks in the same order, which is why that is the rule and why it has no exceptions.

Everything after this is a consequence. A hook in an if shifts every later call by one, so they quietly take each other's values. State has to be replaced rather than mutated, because a list of slots can only be compared by what is in the slot. A dependency array holds one result rather than a table of them, because there is one slot per hook and no room for more. And a callback that outlives its render keeps that render's variables, because that is what a closure is — React's part is only the array that told it not to make a new one.

Next: the same ideas stated compactly, as something to look things up in.

Labs — by Ananda Rizki

Simulator

React Hooks

An interactive tutorial on React hooks as a mechanism rather than an API, for someone who wants to know why the rules exist rather than what they are. It starts before the jargon: a component is a function, a function's variables die when it returns, a closure outlives the call that made it — and React therefore has to keep your state somewhere else and match this render's hook calls to the last render's state with nothing to match on but the order. Ten lessons then trace that machinery one frame at a time, with React 19.1.0's own functions named as they run: mountWorkInProgressHook building the linked list on fiber.memoizedState, updateWorkInProgressHook cloning it cell by cell while currentHook and workInProgressHook walk in lockstep, the dispatcher swapped into ReactSharedInternals.H before your function is called and swapped back the moment it returns. A hook inside an if is not asserted to be dangerous; the same walk runs, the cursor lands on the wrong cell, the variable quietly takes another hook's value, and React's real dev-mode table and its real thrown error are printed verbatim. Three lessons cover state as a value rather than a box — a pushed array that changes the data and nothing else, Object.is deciding a bail-out and disagreeing with === in both directions, and why setCount(c => c + 1) is a different computation rather than a different style. Three more are about closures: a stale one built step by step inside a custom hook, the three fixes and what each one costs, and why a dependency array is one slot rather than a cache. A twelfth view is a recording rather than a drawing — three copies of the same hook running 20 ms intervals on one component, and the arrays they really pushed. A thirteenth runs eleven fixtures through eslint-plugin-react-hooks and shows the one shape it cannot see. Every number on the page was captured from a real render in Chrome, and the last view lists every file, function and script behind it.

Built by Ananda Rizki, a frontend developer — one of the small web apps he writes for fun at Labs.