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
Hi, this is Adam Klein, and this is We Don't Know How Ustate Works. So this talk was born after I heard many times people asked this question on different forums. What about the callback to set states? So people were migrating from using classes and calling set states 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 works under the hood until I started noticing some strange things when doing console logs. And I decided to open up the 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. 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 to know and get extra knowledge about the framework that we're using 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. 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 0, passes it to the updater function, gets the new value, which in our case is 1, updates the current value of the hook to 1, and then triggers a re-render of our component. And our component calls useState again and gets the new value, which React stores, which is 1. So it's pretty simple. The only thing is it's wrong. This is not how it happens. So before I show you how it happens, let's look at some console logs. So 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 onClickEvent handler. And we call setCounter. Let's minimize this with the update function. And 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. We expect it to update the value of the hook and then re-render. But it's the other way around. So let's see how this happens. So when we call setCounter, 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 setCounter. We pass in the update function. All React does is put this update function in a queue. And then it schedules a re-render, which means it turns on some kind of flag that says, this component needs to be re-rendered 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 re-render the entire component tree. Or not the entire component tree, the components that were scheduled for re-render 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, 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, OK, there is an action. Let's perform it. This action updates the hook to 1. And if there are other actions, it will perform them sequentially until we exhaust the queue. And 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 update of function. And let's clear. 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. And the second thing is that we call update. So which means that during the call to useState, React runs the update of function. And only then we call after useState, which is queue. 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 re-render. 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 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 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. What about render bailout? 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 re-render of our component. But we already said that React only runs the updates during the component re-render. So how can it even bail out or re-render 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 re-render. And if it determines that the new state is different than the old state, it will bail out of render, and it will not trigger a re-render. Obviously, it won't do it if we already scheduled a re-render for this component. There's no need to do this eager calculation, because we need to re-render anyway. And also sometimes React will determine that it's not worth doing the calculation. For example, if we already call 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 re-render 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 perform a 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 click, 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 component, 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, OK, 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. 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. So what if we calculate 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 a state to the same value? 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 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. Part three, when does React re-render? So I promised you that I will explain when this happens. So we scheduled the re-render, but when does React actually decide, OK, 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 function 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 call 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 re-render to the component. So in this case, with an async function, we will actually re-render 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. And 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 it 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 our 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 updated functions in older versions of React. Part four, what about reducers? So the answer is pretty simple. Reducer is actually, state is actually a reducer under the hood. 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, or the updated function, 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 returns, it 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. 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? And 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 components 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 you console.log inside the reducer, and you don't see the console.log, now you know why. So to summarize, state is reducer. Everything we said about state holds for reducers. And the next part is a bit philosophical. People keep saying that state updates are asynchronous in React. 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 onClick 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 setCounter and we call afterUpdates. Let's clear the log and see what happens when we call the plus. So as you can see, we called setCounter twice, we console.log 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 doing the same event loop synchronously, React called our event handler, triggered the rerender, 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 actually 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 render still didn't happen. So it feels asynchronous. So again, this is a very complicated explanation. 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 its 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. Goodbye.