A journey through the implementation of the useState hook

Responses (0)

Clap
123|0|

When the React team released hooks last year, my first thoughts were, "what new piece of wizardry is this which has been unleashed upon us?!". Your initial impressions may have been less dramatic but I am sure you shared my sentiments. Digging into the implementation of new framework features is one of my favoured methods of understanding them better, so in this post I will attempt to walk you through part of the workflow that is triggered when the useState hook is used. Unlike other articles which teach you how to use a hook or implement your own version, I am more concerned with the code behind the hook, so to speak.

One way I like to break down features is by focusing on their underlying data structures. Components, hooks, context...all these are React features but when you peel back the layers, what are they really? I find this approach immensely useful because it helps me not to be fixated on framework specific concepts but on the tool used to build the framework - JavaScript.

The first thing we will do is create a very simple example application (they work best for this kind of deep dive):

Our app renders a button which displays a counter and increases it by one it whenever it is clicked. It consists of a solitary function component. Hooks were created to encapsulate side effects and stateful behaviour in such components. If we look at this code through our data structure lens, we can see that:

  • ComponentWithHook is a function which returns an object (all JXS calls are translated into objects by this babel plugin)

  • In ComponentWithHook, we call a function that returns two values which we destructure

Before we go into what happens next, let us remind ourselves of some of the behaviour we expect to see based on how hooks work:

  • the useState hook ensures React preserves the state between re-renders

  • hooks should not be called in loops, nested functions or conditions

  • hooks should not be called outside of function components

Where do hooks live?#

All code taken from React's source is from version 16.12.0

React hooks are stored in a shared ReactCurrentDispatcher object which is first initialised here when the app loads. It has one property called current, which has null as its initial value, and is allocated hook functions corresponding to React's mount or update phase. This allocation happens in the renderWithHooks function:

In the source, there are comments above this code which explain that if the following check nextCurrentHook === null is true, this indicates to React that it is in the mount phase. You might be interested to know that the only difference between the dev and production versions of the HooksDispatcher objects is that the dev hooks contain sanity checks such as ensuring that the second argument for hooks like useCallback or useEffect is an array. At the beginning of our app's lifecycle, our dispatcher object is HooksDispatcherOnMountInDEV.

Calling the hook#

Each time a hook is called, our dispatcher is resolved by this function which checks that we are not trying to call a hook outside of a function component. useState itself actually calls a function called mountState to execute the core of its work:

mountWorkInProgressHook returns an object which starts off with null as the value for all its properties but ends up like this at the end of the function:

In mountWorkInProgressHook we see that return [hook.memoizedState, dispatch] maps to our state initialisation expression const [count, setCount] = React.useState(0).

With regards to the hooks part of React's initialisation, this is where our interest ends. At the beginning of this article we said we would be looking at things from a data structure perspective. React exposes hooks to us as functions but under the hood, they are modelled as objects. Why this is the case will become apparent in the next section.

Updating the hook#

What happens when we click the button and update our counter? From what we know so far, we should expect the answer to that question to also include answers to these sub-questions:

  1. Which state gets updated? Our initial value of 0 is stored in three different places on the hook object

  2. setCount is really dispatchAction.bind(null, currentlyRenderingFiber$1, queue) but dispatchAction does not seem to accept any arguments we pass it. What happens to them?

  3. If we had multiple components or useState hooks, how would React know which hook to call?

The answer to question two is comes from the declaration of dispatchAction. Its expected arguments are dispatchAction(fiber, queue, action). In the mountState function we can see that fiber and queue are already passed in, so action refers to whatever argument we pass to setCount. This makes sense since dispatchAction is bound function.

The argument fiber in dispatchAction provides the answer to question three. I have written about React's new fiber implementation here but essentially, a fiber is an object that is mutable, holds component state and represents the DOM. React creates a tree of these objects and that is how it models the entire DOM. Every component has a corresponding fiber object. You can actually view the fiber node associated with any HTML element by grabbing a reference to the DOM element and then looking for a property that begins with __reactInternalInstance.

The fiber object we get when dispatchAction is for our ComponentWithHook component. It has a property called memoizedState and its value is the hook object created during mountState's execution. That object has a property called next with the value null. If ComponentWithHook had been written like this:

Then memoizedState on its fiber object would be:

Hooks are stored according to their calling order in a linked list on the fiber object. This order is why one of the rules of using hooks is they should not be used in loops, conditions or nested functions. For example, the docs provide the following code to illustrate what could go wrong:

When React first initialises the app, the fiber node for this component has a property called _debugHookTypes with the following array ["useState", "useEffect", "useState", "useEffect"]. When setName is invoked, maybe to clear the name field in the form for instance, the updateHookTypesDev function runs and compares the currently executing hook and its expected index in the array. In the example above, it finds useState instead of useEffect and throws an error. But what if you tried tricking React by writing this:

You will be caught in the renderWithHooks function thanks to the linked list implementation. When the error occurs, React is expecting to be working on useState('Poppins') and for the next property on its hook object to be null. However, in the example above, it encounters the useState('Nyasha') hook instead and find its next property pointing to the hook object for useState('Poppins').

Moving on to our first question (which state field on the hook object gets updated?), we can answer it by looking at the hook object we get once the component has been updated and re-rendered:

A lot has changed. The most significant being queue.last and baseUpdate. Their changes are identical because baseUpdate contains the most recent action that changed baseState. If you were to increment the counter again and pause at the updateFunctionComponent, for example, the action property on queue.last would be 2 but remain as 1 on baseUpdate. The explanation for queue.last's changes are as follows:

  • last.action - is the value we passed to the useState's update function, which in our case is 1

  • last.eagerState - is only set if React's update queue is empty. React takes this opportunity to "eagerly compute" the next state before enters its render phase. React's work is done in two phases - render and commit. During render, React applies updates to components and figures out which UI changes are needed (you can actually pinpoint the current phase by placing a breakpoint on this line of code). In the commit phase, React goes over the list side-effects (the changes) on the new fiber tree and applies them to DOM, leading to visible changes for the user. The eager state computation is done with the basicStateReducer function:

Because it accepts a function which will take the current state as its first argument, we could re-write our setCount function like this:

Some developers advocate giving a function to useState's update function if your state update depends on your previous state. Another thing to note is that if the newly computed state is the same as the current state, React bails out without scheduling a re-render.

  • last.eagerReducer - after our state is updated, the function used to make that change is stored on this property. When React enters the render phase, this allows it to use last.eagerState without calling that function again if it is unchanged.

  • last.next - is a circular linked list which is created on the first update. This is actually not used by the useState hook but by useReducer instead. I cannot comment much on this as I did not look at useReducer in any depth for this post but according to the comments in the source code, this list will be unravelled when the reducer is updated.

  • last.priority - refers to how React decides when to perform certain updates. The new fiber architecture gave React the power to prioritise updates such as user input and animations over lower priority updates such as network responses. The value 98 means this is a user-blocking update, the second highest in React's list after immediate priority updates which have the value 99. Scheduling and managing updates is a tricky problem UI frameworks contend with. According to this commit, the naming of React's updates is influenced by this document on proposals around how to deal with this issue.

I came across many references to reducers whilst looking at useState and this is because the function which actually does the state update is called updateReducer. It is also used to perform updates by the useReducer hook. As I said earlier, if last.eagerState is computed, it means React has not yet entered the render phase. When it eventually does so, it realises that the new state has already been "eagerly computed", so it is applied to the lastRenderedState, memoizedState and baseState properties.

Summary#

We began this article by asking what happens when we introduce the useState hook to a codebase. It involves the creation of a shared object and a linked list. This post is by no means an exhaustive or complete explanation of useState but I hope it has provided some interesting insight into React's internals. As I mentioned at the outset, focusing on the data structures behind library and framework features can yield some interesting learnings. From a developer's point of view, hooks are functions which encapsulate stateful code and side effects but internally, React is working on a linked list. I have found this approach not only educational but empowering because it reminds me that despite the complexity of the tools we use, they are built using some of the JavaScript language features you and I use daily.



Clap
123|0