A journey through the implementation of the useState hook
Responses (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):
const ComponentWithHook = () => {
const [count, setCount] = React.useState(0);
return (
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
)
}
ReactDOM.render(
<ComponentWithHook />,
document.getElementById("root")
);
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-rendershooks 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:
{
if (nextCurrentHook !== null) {
ReactCurrentDispatcher$1.current = HooksDispatcherOnUpdateInDEV;
} else if (hookTypesDev !== null) {
// This dispatcher handles an edge case where a component is updating,
// but no stateful hooks have been used.
// We want to match the production code behavior (which will use HooksDispatcherOnMount),
// but with the extra DEV validation to ensure hooks ordering hasn't changed.
// This dispatcher does that.
ReactCurrentDispatcher$1.current = HooksDispatcherOnMountWithHookTypesInDEV;
} else {
ReactCurrentDispatcher$1.current = HooksDispatcherOnMountInDEV;
}
}
var children = Component(props, refOrContext);
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:
function mountState(initialState) {
var hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
initialState = initialState();
}
hook.memoizedState = hook.baseState = initialState;
var queue = hook.queue = {
last: null,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState
};
var dispatch = queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber$1, queue);
return [hook.memoizedState, dispatch];
}
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:
{
memoizedState: 0, // our initial state
baseState: 0, // our initial state
queue: {
last: null,
dispatch: dispatchAction.bind(null, currentlyRenderingFiber$1, queue),
lastRenderedReducer: basicStateReducer(state, action),
lastRenderedState: 0, // our initial state
},
baseUpdate: null,
next: null,
}
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:
Which state gets updated? Our initial value of
0
is stored in three different places on the hook objectsetCount
is reallydispatchAction.bind(null, currentlyRenderingFiber$1, queue)
butdispatchAction
does not seem to accept any arguments we pass it. What happens to them?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:
const ComponentWithHook = () => {
const [count, setCount] = React.useState(0);
const [bool, setBool] = React.useState(false);
React.useEffect(()=>{}, [count, bool])
const child = React.useRef();
return (
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
)
}
Then memoizedState
on its fiber
object would be:
{
memoizedState: 0, // the setCount hook
baseState: 0,
queue: { /* ... */},
baseUpdate: null,
next: { // the setBool hook
memoizedState: false,
baseState: false,
queue: { /* ... */},
baseUpdate: null,
next: { // the useEffect hook
memoizedState: {
tag: 192,
create: () => {},
destory: undefined,
deps: [0, false],
next: { /* ... */}
},
baseState: null,
queue: null,
baseUpdate: null,
next: { // the useRef hook
memoizedState: {
current: undefined
},
baseState: null,
queue: null,
baseUpdate: null,
}
}
}
}
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:
function Form() {
// 1. Use the name state variable
const [name, setName] = useState('Mary');
// 2. Use an effect for persisting the form
if (name !== '') {
useEffect(function persistForm() {
localStorage.setItem('formData', name);
});
}
// 3. Use the surname state variable
const [surname, setSurname] = useState('Poppins');
// 4. Use an effect for updating the title
useEffect(function updateTitle() {
document.title = name + ' ' + surname;
});
// ...
}
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:
function Form() {
// 1. Use the name state variable
const [name, setName] = useState('Mary');
// 2. Use the middle name state variable
if (name !== '') {
const [middleName, setmiddleName] = useState('Nyasha');
}
// 3. Use the surname state variable
const [surname, setSurname] = useState('Poppins');
// ...
}
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:
{
memoizedState: 1, // our new state
baseState: 1, // our new state
queue: {
last: {
expirationTime: 1073741823,
suspenseConfig: null,
action: 1,
eagerReducer: basicStateReducer(state, action),
eagerState: 1, // our new state
next: { /* ... */},
priority: 98
},
dispatch: dispatchAction.bind(null, currentlyRenderingFiber$1, queue),
lastRenderedReducer: basicStateReducer(state, action),
lastRenderedState: 1, // our new state
},
baseUpdate: {
expirationTime: 1073741823,
suspenseConfig: null,
action: 1,
eagerReducer: basicStateReducer(state, action),
eagerState: 1, // our new state
next: { /* ... */},
priority: 98
},
next: null,
}
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 theuseState
's update function, which in our case is1
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 thebasicStateReducer
function:
function basicStateReducer(state, action) {
return typeof action === 'function' ? action(state) : action;
}
Because it accepts a function which will take the current state as its first argument, we could re-write our setCount
function like this:
<button onClick={() => setCount(count => count + 1)}>Count: {count}</button>
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 uselast.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 theuseState
hook but byuseReducer
instead. I cannot comment much on this as I did not look atuseReducer
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 value98
means this is a user-blocking update, the second highest in React's list after immediate priority updates which have the value99
. 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.