We use them all the time, and we think we know state hooks work (useState, useReducer). But under the hood, as expected, things are not what you imagine. This talk is about update queues, batching, eager and lazy updates, and some other cool things learned from looking at Hooks source code. It also contains practical takeaways that will help you better understand and debug your code.
We Don’t Know How React State Hooks Work
AI Generated Video Summary
This talk explores how useState works under the hood and why it's important to understand. It addresses the common confusion around the callback to setState and provides insights gained from exploring React hooks source code. Knowing how useState works is important for learning patterns, debugging, and gaining confidence in our code. React manages the current value of hooks in a linked list and performs updates sequentially. React optimizes rendering by caching computations and performing shallow renders when the state doesn't change.
1. Introduction to useState
This talk explores how useState works under the hood and why it's important to understand. It addresses the common confusion around the callback to setState and provides insights gained from exploring React hooks source code.
Hi, this is Adam Klein, and this is We Don't Know How UseState Works. So, this talk was born after I heard many times people asked this question on different forums. What about the callback to setState? So people were migrating from using classes and calling setState, to using hooks and calling useState. And they were missing this feature that you can add a callback after the state is updated. People asked, how do I do that with hooks? Which made me realize that people just don't know how useState works under the hood. I didn't know how useState worked under the hood until I started noticing some strange things when doing console logs. And I decided to open up React hooks source code. And I found out some great things under the hood. Which made me realize if people knew this, they wouldn't ask about the callback of setState.
2. Importance of Understanding useState
Knowing how useState works under the hood is important for several reasons: it's fun, it allows us to learn patterns that can be applied to other projects, and it helps with debugging and gaining confidence in our code.
So before we dive into, let's ask ourselves, why do we need to know how it works under the hood? I mean, we're using it, we know how to use it. So there are three reasons, in my opinion. First of all, it's fun. It's fun to know and get extra knowledge about the framework that we use every day. It demystifies the magic. The second thing is that we might see patterns that we can learn from and implement in our own projects, which are unrelated to React, even. And the third, I think, is that when you're debugging code and weird stuff happens, it's very helpful to know how it works under the hood and gain more confidence about the code that you write.
3. Understanding setCounter
Let's explore what happens when we call setCounter in a simple component using useState. React manages the current value of hooks in a linked list and triggers a rerender after updating the hook's value. However, this common understanding is incorrect.
So let's take this code, which is a simple component, and it uses a state, which is called the counter, and we have a button with an onClick event handler. And in the onClick event handler, we call setCounter, and we pass in an updater function.
Now, this could also be a value, but as you know, useState also supports updater functions, which receive the previous value and return a new value. So let's dive into what happens when you call setCounter. And this is what I used to think.
Now, I know and you might know that React manages the current value of the hooks for us in some kind of ordered data structure, which is a linked list of hooks, and they are called by the order. So what I thought happens is we call setCounter. React sees that we passed in an updater function, so it takes the current value, which is zero, passes it to the updater function, gets the new value, which in our case is one, updates the current value of the hook to one, and then triggers a rerender of our component, and our component calls usetate again and gets the new value, which React stores, which is one. So it's pretty simple. The only thing is, it's wrong. This is not how it happens.
4. Understanding setCounter in React
When calling setCounter in React, the update function is added to a queue and a rerender is scheduled. The component is rerendered after all updates are processed. useState checks for queued updates during the render and performs them sequentially. This explains the order of rendering and updating in React components.
So before I show you how it happens, let's look at some console logs. Here we have a component which does pretty much the same thing, and let's see what happens when we click the plus button. We have an onclick event handler, and we call setcounter. Let's minimize this with the update function. Here we have a console log of update, and here we have a console log of render. And when we click the increment button, let me just move this over here, we render first, and then we call the update function, which is exactly the opposite of what we expect, right?
We expect it to update the value of the hoop and then re-render, but it's the other way around. So let's see how this happens. So when we call set counter, there's actually another linked list attached to each of our hooks, and this is the update queue. So every update that we perform on our state is not performed immediately. It's added to the queue, and it will be processed during render. Let's see how this works.
So we call set counter, we pass in the update function. All React does is put this update function in a queue, and then it schedules a rerender, which means it turns on some kind of flag that says, this component needs to be rerendered after we finish all of the updates. Now we might have more updates. We might update this hook again. We might update other hooks. We might call the parent component callback, which will update some stuff. We might dispatch some Redux action that will update other components. And all of these updates will be queued. And then at some point, and we will discuss when it happens. React decides to rerender the entire component tree, or not the entire component tree, the components that were scheduled for rerender from top to bottom. So according to the hierarchy of the component tree. And as I mentioned, we will discuss when this happens later. And now, and only now, when we actually re-render the component, React looks at what we call useState. When we actually call useState, during the render, React checks to see if there are any updates that are queued, and only then it will perform these updates. So during render, while we call useState of this specific hook, React will look and see, okay, there's an action, let's perform it, this action updates the hook to one, and if there are other actions, it will perform them sequentially until we exhaust the queue. So let's add another console log, minimize this. So now, as you can see, we added another console log after useState. So we console log before useState, after useState, and during the updater function, and let's do some updates, clear the log, and when we click plus, you can see that the first thing that happens is that we render. The second thing is that we call update, so which means that during the call to useState, React runs the updater function, and only then we call after useState, which is here.
5. Updating State and Mimicking setState
Every update to a hook is queued and performed lazily during hook invocation. The state is updated only during render. useEffect or useLayoutEffect can be used to perform actions after the state is updated. This is the correct way to mimic the behavior of the updated function of setState.
It's always good to see for yourself what people teach you. So to summarize this part of the talk, every update to a hook is queued. After all updates are done, the components rerender, and the updates are performed lazily during hook invocation. Lazily means that React performs them only at the exact time when they are needed, and not before.
So now we can, first of all, answer this question. How do we call the callback to the setState when we're using useState? So now we can answer, when is the state updated? Only during render. So how can we do something after the state is updated? We call useEffect or useLayoutEffect, which is something to be performed after the render happens. Okay. And obviously we can pass this state as a dependency of the useEffect so we can know when this state was updated exactly, and we can react to it. So this is a standard way to mimic the behavior of the updated function of setState. It's the correct way to do it.
6. Understanding renderBailout
React eagerly calculates the new state before triggering a rerender. If the new state is different, it bails out of the render. React optimizes render time by heuristics. The new calculated value is cached in the update queue, avoiding recomputation. The first time a value is updated, React re-renders if a new value is returned. Subsequent updates dismiss this optimization.
What about renderBailout? So here, let's say we have again a counter, but we set it to the exact same value. Now we expect React to know that it's the same value and not trigger a rerender of our component. But we already said that React only runs the updates during the component's rerender, so how can it even bail out or rerender the component if it calculates everything lazily? It doesn't even know that we set it to the same value.
So I cheated. There's actually another step that happens sometimes, which is that React eagerly calculates the new state before triggering a rerender. And if it determines that the new state is different than the old state, it will bail out of the render and it will not trigger a rerender. Obviously, it won't do it if we already scheduled a rerender for this component. There's no need to do this eager calculation, because we need to rerender anyway. And also sometimes React will determine that it's not worth doing the calculation. For example, if we've already called the same callback a few times and every time it returns a new value, React just assumes that it will return a new value. So it's just heuristics that React runs to optimize our render time.
So, obviously, if the new calculated value is different, React will schedule a rerender and will continue the flow, except that because it already computed the new value, it will cache the result inside the update queue. So when we will actually call useState and perform the update queue, React already has this result inside the queue. So it still didn't update the actual value of the hook, but it did store the result of this specific action. So we call the action, we don't have to compute it again because the result is queued. We update the value of the hook and we continue with the render. So now, the reason I say I cheated, is because every time I showed you console logs, I clicked the plus button a few times, to avoid showing you this weird behavior that happens the first time you update the value.
So the first time I clicked, you can see that, the first thing that happens is that React tries to update the state. It calls the update function. So for the first time, React says, let's see what happens. And it does the eager calculation, and it sees that the new value is equal to the previous value and it re-renders the components, and only then it shows after useState. Now, the reason that it re-renders is because we actually returned a new value. So we had zero and the new value is one. So it says, okay, we need to re-render the component. And then when we call useState, one is already cached. So React doesn't call the update function again. And then we get to the after useState. So this is for the first time. The second time, React already dismisses this optimization, because the last time we called the update function, it returned a new value. So it just assumes that the next time it will also return a new value.
7. Optimization and Shallow Rendering in React
React optimizes the rendering process by computing eagerly and caching the computation. If the state doesn't change, React performs a shallow render and stops further re-rendering. This behavior is explained in the React documentation.
So I don't know exactly how React optimizes this. I didn't see any other patterns. Obviously, it's something that they can change all the time. So it's not part of the documentation, and you can't really rely on how this optimization will work. And so what if we calculated the state lazily? But still it doesn't change. So React didn't do the eager calculation. It did lazily, but still it didn't change. So does it still re-render everything, even though the only thing that happened was we updated the state to the same value? A long question, short answer, no. It will not continue to re-render. It will just do a shallow render of this component, and if it will find out that nothing actually changed, it will stop there. And you can also check out the React documentation that explains exactly this. So to summarize this part, React sometimes computes eagerly to bail out of render. It caches the eager computation to avoid re-computing. And anyway, if the re-computation is done, if the computation is done lazily and the state didn't update, it will bail out of render and just complete the shallow render of our component.
8. When does React re-render? What about reducers?
When does React re-render? React decides to re-render after updating is complete. Event handlers in React run in batches, and every update to a hook is queued. Async functions trigger immediate re-renders. To avoid flickering and optimize renders, use synchronous event handlers or react-dom unstableBatchUpdates. A bug in older React versions occurs when using recycled events inside updated functions. Upgrade to new versions or extract values before using them.
Part three. When does React re-render? So I promised you that I will explain when this happens, so we schedule the re-render, but when does React actually decide, okay, we're done with updating, let's re-render? So again, let's take an example. We have an onclick event handler. And every event handler in React runs inside a batch, which means that when we call this onclick, React runs it inside some kind of batch function internally. And every update to a hook will be queued. So the first time we call setCounter, it will add an action to the queue. The second time we call setCounter, it will add an action to the queue. And after the function finishes, it will trigger a re-render. So you probably ask yourself, OK, but what about async functions? If we call something async, then the actual function is complete before we get to the asynchronous code. So React can't really know that you are trying to run updates to the state. It doesn't know when this async function will complete. So the batch is done immediately, and then every call to setCounter will trigger a rerender to the component. So in this case, with an async function, we will actually rerender the component twice. So this is important to know in case you want to avoid some kind of flickering of the UI, or you want to micro-optimize your renders. And then you should know that this will trigger a render twice. It's usually not a problem, but it helps to be aware of the difference between synchronous functions and asynchronous functions. And if you really want to do this inside a batch, you can call react-dom unstableBatchUpdates. And this will do the same behavior as React does when you actually call a synchronous event handler. And so when you call unstableBatchUpdates, it will only render once, even though you updated the counter twice. The only thing is that this is a function called unstable underscore something, it obviously deters us from using it. But it's pretty safe to use it if you really need to, because React, the team realizes that a lot of people use it and some libraries and frameworks use it. So they actually declared it not as part of the public API, but they don't make breaking changes to this API because they realize a lot of people use it. And also they are going to support in the new versions of React, batching updates in a more stable way. So bonus step is, where is the bug? This is more relevant to previous versions of React, but a lot of you are probably still using these versions. So what is the bug with this code? We have an event handler and we call setstep, passing an updated function and we use e.target.value. The problem with this code, again, in previous React versions, is that we are using E, which is an event, and React recycles the events, which means that, and now we know that this updated function will only run during the next re-render, where React already could have reused the synthetic event that is using inside the event handler. So the way to solve this is, first of all, upgrade to new React versions, when you don't have this problem. And the second result, if you can't migrate, is just to extract the value before and then use it inside the updated function, or call persistEvent, which persists the event and prevents it from being re-rendered. So to summarize this part, React performs event handlers in a batch, async code won't run in a batch unless we use unstable batched updates or the new APIs from React, and events are recycled, don't use them inside the updated functions in older versions of React.
Part 4, what about reducers? So the answer is pretty simple, reducer is actually, state, is actually a reducer under the hood.
9. Understanding setState and Reducer
The setState function is a dispatch function that takes the value or updated function as an argument. The reducer in setState, called basicStateReducer, uses the previous state and the action to determine whether the action is a function or a new value. If it's a function, it calls the function with the previous state and returns the result. Otherwise, it returns the new value. This behavior applies to both useState and useReducer.
And the setState function is actually a dispatch function, we're dispatching the value or the updated function, and the reducer of setState just takes the value and uses it on the previous value. So this is actual code from React, this is the reducer that useState uses, it's called basicStateReducer, it takes the previous state and the action, and then it checks if the action is a function which means we passed in an updated function, it just calls this action with the previous state and returns the result, otherwise we just passed in the new value so it just returns the new value. So as you can see, we check if it's a function, if it's a function we call it, otherwise we just return the value. Which means that everything that we said about how useState works, it holds exactly the same for useReducer.
10. Reducer Execution and Render Cycle
If we dispatch an action, the reducer may not always run. There are scenarios where the render cycle may be interrupted, or the component may be unmounted before the render cycle. In such cases, the queued updates are dismissed, and the reducer doesn't run. So if you're not seeing the console log inside the reducer after dispatching an action, this could be the reason.
And now another question, if we dispatch an action, will the reducer always run? Can we guarantee that the reducer will run if we dispatch an action? So the answer is no, because we only call the reducer during the render cycle. And we might not even get to the render cycle. First of all, there might be some exception that will stop the render in the middle. And second, our elements might be even unmounted in the next render cycle, because remember, we call setState, but we can also call some kind of callback in our parent component. And maybe this callback will change some kind of flag that will hide our component in the next render cycle. So we might not even call the render function of this component again, which means that we just dismiss the queue of the updates, and the reducer won't even run. So again, if you dispatch an action and your console log inside the reducer, and you don't see the console log, now you know why.
11. Asynchronous State Updates in React
State updates in React can be asynchronous or deferred depending on the context. When calling set counter, the update may happen synchronously or asynchronously, depending on concurrent mode and batching. The rendering process in React can be delayed or deferred to prioritize other tasks. This complexity can lead to confusion about the synchronous or asynchronous nature of state updates.
So to summarize, state is reducer, everything we said about state calls for reducers. And the next part is a bit philosophical. People keep saying that state updates are asynchronous in React. And if you search, google it, you get a lot of different answers, which determine that setState is asynchronous, which is pretty true, but there are a few nuances to know.
So like everything in Facebook, it's complicated. So state updates may be asynchronous. Let's just look at some console logs and describe it the best way we can. So now what I want to show you is an on-click event handler. I call promise resolve and console.log. So this will be logged immediately after the current event loop, which means after all of the synchronous tasks. Then we call set counter and we call after updates. Let's clear the log and see what happens when we call the plus. So as you can see, we called set counter twice. We console logged it twice and these are the first things that we log inside our console. Which makes sense because they happened synchronously.
You can also see that the render happened here and it happened before the async code, which was here. Which means that during the same event loop, synchronously React called our event handler, triggered the re-render, and called our render function of the component. Everything was performed synchronously inside the same event loop. And only after the render was completed, then we saw this async log of promise. Which confuses us a bit, because we just said that state updates are asynchronous, but we just saw that it happens synchronously.
So there are a few answers to this question. First of all, in concurrent mode, it could be asynchronous in a different event loop. Because React may delay the rendering of our components in favor of more prioritized tasks. So it could actually be performed truly asynchronously. And when we do batching, you can look at it as deferred. It's not truly asynchronous in terms of the event loop, but it's deferred. We call the set counter, and then we move on to our next statement. And the renders still didn't happen. So it feels asynchronous. So again, this is a very complicated explanation.
12. Understanding State Updates
State updates in React can be considered asynchronous. They are queued and computed lazily during render. React bails out of render if the state doesn't change. Updates are batched inside Event Handlers, and state is actually a reducer.
It's much easier to just think of it as asynchronous and this is what basically React recommends us. First of all, because of concurrent mode where it can truly be asynchronous. And second of all, because it's implementation details. It doesn't want us to rely on the fact that it happens during the same event loop. So the best way to simplify it is to think of state updates as asynchronous.
To summarize this part, state updates are queued and computed lazily during render. React bails out of render if the state doesn't change. Updates are batched inside Event Handlers, and state is actually a reducer. And lastly, state updates should be considered asynchronous.
Thank you. I hope you enjoyed the talk. I surely did, and I hope you learned some cool stuff. And if there are any questions, let me know. And see you all next time.