We don't know how React state hook works
adam klein
Posted on July 8, 2020
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
Posted on July 8, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 18, 2024