Pragusga
← Back to blog

How useState works under the hood

A quick look at what React is doing when you call useState, and why the rules of hooks exist.

You use useState all the time. You call it, you get back a value and a setter, and you move on. But have you ever wondered how React actually keeps that state around between re-renders? Like, your component function runs again and again. So where does the number or the string live?

Here's the short version: React keeps a list of hooks per component instance. When your component runs, it goes through the hooks in order and either creates new state (first run) or gives you back the one it stored last time. So the "magic" is basically: React has a place to stash things per component, and it uses the call order to know which stash belongs to which hook.

What React needs to do

Every time your component function runs, React needs to give you the same state back for the same "slot." So the first time you call useState(0), React creates a slot, stores 0, and returns [0, setState]. The next time your component runs, it doesn't create a new slot. It looks at the first hook in the list, sees it's a state hook, and returns the value it saved last time plus the same setter.

So under the hood you can imagine something like this. React has a list per fiber (the internal representation of your component):

// Not real React code, just the idea
let currentFiber = null;
let hookIndex = 0;

function useState(initialValue) {
  const fiber = currentFiber;
  const hooks = fiber.hooks || (fiber.hooks = []);

  if (hooks[hookIndex] === undefined) {
    hooks[hookIndex] = {
      value: typeof initialValue === 'function' ? initialValue() : initialValue,
      setter: (newValue) => {
        // schedule update, then in the next render this hook will have the new value
        hooks[hookIndex].value = typeof newValue === 'function'
          ? newValue(hooks[hookIndex].value)
          : newValue;
        scheduleRerender(fiber);
      }
    };
  }

  const pair = [hooks[hookIndex].value, hooks[hookIndex].setter];
  hookIndex++;
  return pair;
}

Again, that's simplified. The real thing has batching, concurrent updates, and so on. But the idea is: there's an array of hook state per component, and the index is just "how many hooks have we run so far this render."

That's why you can't call hooks inside conditions or loops. If you do:

function Bad() {
  const [a, setA] = useState(0);
  if (someCondition) {
    const [b, setB] = useState(1);  // sometimes this runs, sometimes it doesn't
  }
  const [c, setC] = useState(2);
}

then on the first render you might have three hooks (a, b, c). On the next render, if someCondition is false, you only run two hooks before the second useState. So React would think the second hook is c and give it the state that used to belong to b. Order would be messed up and state would get mixed between hooks. So React enforces: same number of hooks, in the same order, every time.

The setter and re-renders

When you call the setter, React doesn't update the DOM immediately. It marks that component (and usually its subtree) as needing to re-render. Then when it's safe (after the current work is done, or in the next tick), it runs your component again. This time when it hits that useState, the slot already has the new value, so you get [newValue, setState].

If you pass a function to the setter, React will call it with the previous state and use the return value:

setCount((prev) => prev + 1);

That way you're always working with the latest value, even inside closures or when multiple updates are batched. So the "under the hood" part here is: the setter can accept either a value or a function. If it's a function, React calls it with the current state and stores the result.

Why initial state is only used once

You might have seen this: useState(expensiveComputation()). People say "that runs every render!" and tell you to do useState(() => expensiveComputation()) instead. The reason is exactly what we said. On the first render, React creates the hook slot and needs an initial value. So it uses whatever you passed. On every later render, it doesn't create a new slot. It ignores your argument and just returns the stored value. So if you pass expensiveComputation() as the argument, that function runs on every render just to produce a value that React throws away. If you pass a function, React only calls it once when creating the slot (lazy initial state). So the rule of thumb: if the initial value is cheap, pass it directly; if it's expensive, pass a function that returns it.

So that's the mental model. React keeps an array of hook state per component, walks that array in order every time the component runs, and either creates new state or returns the existing one. The rules of hooks are there so that order never changes. Once you get that, things like "why can't I put useState in an if" and "why does my setState feel async" make a lot more sense.