DEV Community

adam klein
adam klein

Posted on • Updated on

We don't know how React state hook works

This article is about:

  • When is the state updated
  • The update queue and lazy computation
  • Batching
  • useState vs. useReducer
  • Performance optimizations
    • eagerly computing state updates
    • shallow rendering and bailing out
  • Will the updater function always run?

When is the state updated?

Look at this code:

const MyComp = () => {
  const [counter, setCounter] = useState(0);

  onClick = () => setCounter(prev => prev + 1);

  return <button onClick={onClick}>Click me</button>
}

What would you imagine happen after the button is clicked and setCounter is called? Is it this:

  • React calls the updater function (prev => prev + 1)
  • Updates the hook's state (= 1)
  • Re-renders component
  • Render function calls useState and gets updated state (== 1)

If this is what you imagine - then you are wrong. I was also wrong about this, until I did some experiments and looked inside the hooks source code.

The update queue and lazy computation

It turns out, every hook has an update queue. When you call the setState function, React doesn't call the updater function immediately, but saves it inside the queue, and schedules a re-render.

There might be more updates after this one, to this hook, other hooks, or even hooks in other components in the tree.
There might be a Redux action that causes updates in many different places in the tree. All of these updates are queued - nothing is computed yet.

Finally, React re-renders all components that were scheduled to be rendered, top-down. But the state updates are still not performed.

It's only when useState actually runs, during the render function, that React runs each action in the queue, updates the final state, and returns it back.

This is called lazy computation - React will calculate the new state only when it actually needs it.

To summarize, what happens is this (simplified):

  • React queue's an action (our updater function) for this hook
  • Schedules a re-render to the component
  • When render actually runs (more about this later):
    • Render runs the useState call
    • Only then, during useState, React goes over the update queue and invokes each action, and saves the final result in the hook's state (in our case - it will be 1)
    • useState returns 1

Batching

So when does React say: "OK, enough queueing updates and scheduling renders, let me do my job now"? How does it know we're done updating?

Whenever there's an event handler (onClick, onKeyPress, etc.) React runs the provided callback inside a batch.
The batch is synchronous, it runs the callback, and then flushes all the renders that were scheduled:

const MyComp = () => {
  const [counter, setCounter] = useState(0);

  onClick = () => { // batch starts
    setCounter(prev => prev + 1); // schedule render
    setCounter(prev => prev + 1); // schedule render
  } // only here the render will run
  return <button onClick={onClick}>Click me</button>
}

What if you have any async code inside the callback? That will be run outside the batch. In this case, React will immediately start the render phase, and not schedule it for later:

const MyComp = () => {
  const [counter, setCounter] = useState(0);

  onClick = async () => {
    await fetch(...); // batch already finished
    setCounter(prev => prev + 1); // render immediately
    setCounter(prev => prev + 1); // render immediately
  }
  return <button onClick={onClick}>Click me</button>
}

State is Reducer

I mentioned earlier that "React runs each action in the queue". Who said anything about an action?

It turns out, under the hood, useState is simply useReducer with the following basicStateReducer:

function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action;
}

So, our setCounter function is actually dispatch, and whatever you send to it (a value or an updater function) is the action.

Everything that we said about useState is valid for useReducer, since they both use the same mechanism beind the scenes.

Performance optimizations

You might think - if React computes the new state during render time, how can it bail out of render if the state didn't change? It's a chicken and egg problem.

There are 2 parts to this answer.

There's actually another step to the process. In some cases, when React knows that it can avoid re-render, it will eagerly compute the action. This means that it will run it immediately, check if the result is different than the previous state, and if it's equal - it will not schedule a re-render.

The second scenario, is when React is not able to eagerly invoke the action, but during render React figures out nothing changed, and all state hooks returned the same result. The React team explains this best inside their docs:

Bailing out of a state update
If you update a State Hook to the same value as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)

Note that React may still need to render that specific component again before bailing out. That shouldn’t be a concern because React won’t unnecessarily go “deeper” into the tree. If you’re doing expensive calculations while rendering, you can optimize them with useMemo.

https://reactjs.org/docs/hooks-reference.html#bailing-out-of-a-state-update

Put shortly, React may run the render function and stop there if nothing changed, and won't really re-render the component and its children.

Will the updater function always run?

The answer is no. For example, if there's any exception that will prevent the render function from running, or stop it in the middle, we won't get to the useState call, and won't run the update queue.

Another option, is that during the next render phase our component is unmounted (for example if some flag changed inside the parent component). Meaning the render function won't even run, let alone the useState expression.

Learned something new? Found any mistakes?

Let me know in the comments section below

Latest comments (8)

Collapse
 
armyofda12mnkeys profile image
armyofda12mnkeys

I was reading above and Mark's link in this area:
blog.isquaredsoftware.com/2020/05/...

Still having a bit of trouble grasping... I'm curious why setCompleted needs to use an updater function here in the first "Fix a request counter" challenge:
beta.reactjs.org/learn/queueing-a-...
I get why pending needs to, since a few seconds later it needs to decrement the value which was just incremented... but why does setCompleted need the updater function for its own variable. Hence why I was wondering why "setCompleted(completed + 1);" wouldn't have worked.

Collapse
 
markerikson profile image
Mark Erikson

FWIW, I have an extensive post at A (Mostly) Complete Guide to React Rendering Behavior that goes into more detail on these topics, including batching, when state updates are applied, and perf optimizations.

Collapse
 
alexkubica profile image
Alex Kubica 🇮🇱

Thanks! I learned some new things here.
So if React has useReducer does it mean redux comes with React?
Also, I didn't understand what happens in basicStateReducer if the action isn't a function, how does the state get updated?

Collapse
 
adamklein profile image
adam klein

Hi, glad to hear!
useReducer is a local state hook: reactjs.org/docs/hooks-reference.h...
Redux is not part of React, it's a 3rd party library that manages global state
There are a lot of similarities because both of them update state using a reducer and dispatching actions.

If basicStateReducer receives a value that is not a function, it sets it as the new state.

So imagine a reducer that looks like this for plain values: (state, action) => action

Collapse
 
ryanmaffey profile image
Ryan Maffey

Really great read, learned a lot. Thank you!

Collapse
 
kosich profile image
Kostia Palchyk • Edited

Thank you, Adam! I've learned something new!

BTW, tried this in a REPL: stackblitz.com/edit/react-sync-asy...
Theres seem to be an anomaly when the state is updated for the first time: it calculates first updater immediately. All consequent updates work as you describe! Probably another optimization, don't want to dig sources for that 😢🙂

Again, thanks!

UPD: That's the eager computation, see Adam's comment 👇

(P.S: theres a typo in "beind")

Collapse
 
adamklein profile image
adam klein

Hi,
glad you learned something :)

That's the eager computation.

I think React tries to optimize based on the prediciton that the previous outcome will repeat itself.
So if the last time the state changed, it will start with lazy computation. But if the last time the state didn't change - it will start from eager computation. But that's just my guess, I haven't seen that in the code and I honestly have no idea.

I tried to demonstrate it here (forked your stackblitz):
stackblitz.com/edit/react-sync-asy...

Collapse
 
kosich profile image
Kostia Palchyk • Edited

Right you are! Thanks and sorry, missed that one while reading first time.