Building a React-Like Library: useState
This is the third of four issues where we build a React-like library to learn how React works under the hood.
Building a React-Like Library
The series contains the following articles:
Implement useState. This issue
Video
If you prefer, I recorded a live-coding video explaining this article. Feel free to complement this article with it, or go and watch that instead.
Context
This article is the third entry in the series: Building a React-Like Library. In the previous article, we learned how to create a virtual DOM with createElement
and how to render functional components.
const render = (vnode, parent) => {
// manage string type
if (typeof vnode === "string") {
return parent.appendChild(document.createTextNode(vnode));
}
// manage functional component
if (typeof vnode.tag === "function") {
const nextVnode = vnode.tag();
render(nextVnode, parent);
return;
}
// create HTML element
const element = document.createElement(vnode.tag);
// set attributes
if (vnode.props) {
Object.keys(vnode.props).forEach(key => {
const value = vnode.props[key];
element.setAttribute(key, value);
});
}
// iterate over children and render them
if (vnode.children) {
vnode.children.forEach(child => render(child, element));
}
// append the element created
return parent.appendChild(element);
};
const createElement = (tag, props, children) => ({
tag,
props,
children
});
const Title = () => createElement("h1", {}, ["Hello from dynamic app!"]);
const vDom = createElement(
"div",
{},
[
createElement(Title, {}, []),
createElement("p", {}, ["This was built with createElement"])
]
);
render(vDom, document.getElementById("root"));
Let’s add some more fun by allowing the counter to work. Every time we click the “+” button, we want to increment the number.
The code inside the Counter that we want to support will be the same as React:
const Counter = () => {
// we’ll implement the useState functinality
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1);
};
return createElement("div", {}, [
createElement("button", { onClick: increment }, ["+"]),
createElement("h3", {}, [count]),
createElement("button", {}, ["-"]),
]);
};
Notice the new functionality with const [count, setCount] = useState(0);
.
Step 1: Event Listener
The first step towards our goal is to support event listeners. We need to transform the prop onClick
into an event listener.
createElement("button", { onClick: increment }, ["+"]),
In vanilla JS to add an event listener, we write element.addEventListener(“click”, …)
.
Let’s review the code that manages the props inside the render
function:
if (vnode.props) {
Object.keys(vnode.props).forEach(key => {
const value = vnode.props[key];
element.setAttribute(key, value);
});
}
We must differentiate when the prop is an attribute or an event listener. We can say that all the props that start with “on” are event listeners. And that the part after “on” is the event: “on<Event>”.
If we implement this rule, we have the following:
if (vnode.props) {
Object.keys(vnode.props).forEach(key => {
const value = vnode.props[key];
If (key.startsWith(“on”)) {
// get the event from “on<Event>”, eg: “onClick” → “click”
const eventName = key.slice(2).toLowerCase();
// the “value" is the function to be called
element.addEventListener(eventName, value);
} else {
element.setAttribute(key, value);
}
});
}
Step 2: Setup useState
The following is how we utilize useState
:
const [count, setCount] = useState(0);
Therefore, useState
returns an array. The first element is the state; the second is a function. This second function expects the new value of the state.
We call this tuple of the state plus the function to change the state a “hook”. useState
returns a hook.
The following is a placeholder implementation:
const useState = (initialState) => {
const hook = [initialState, (newState) => {
console.log('in da set state');
console.log("next state:", newState);
}];
return hook;
};
Step 3: Rerender On State Change
The counter still needs to increment when we click the button. So let’s take a look at what we do inside the setState
:
(newState) => {
console.log('in da set state');
console.log("next state:", newState);
}
We need to rerender the app as well. Something like the following:
(newState) => {
console.log('in da set state');
renderApp();
}
Let’s implement renderApp
:
const renderApp = () => {
render(vDom, document.getElementById("root"));
};
Let’s try it:
The whole app is duplicated with each click. That’s because in render
, we append the app to the parent element.
To fix this, we can empty the root element and then render the app..
const renderApp = () => {
const root = document.getElementById("root");
root.innerHTML = "";
render(createApp(), root);
};
We don’t have the duplication now, but the number is not changing even though the function is called, as we see in the previous gif.
NOTE: This is not ideal in a production environment because we re-render the whole app only when a number changes.
Step 4: Store State
This implementation has a big problem; every time our Counter
is rendered, a new setCount
and count
are created. useState
needs to keep track of the same state across render calls and is failing to do so.
Therefore, let’s store the array in a global variable to keep track of all the hooks.
// array of hooks
const hooks = [];
const useState = (initialState) => {
const hook = [initialState, (newState) => {
console.log('in da set state');
console.log("next state:", newState);
}];
// push the state inside the array of hooks
hooks.push(hook);
return hook;
};
The previous still creates and returns a new state. So, how do we know which state from hooks
to return? One way is to keep track of the calls to useState
.
In React, useState
calls can’t be inside loops or conditional statements. Therefore, the calls of useState
inside a component always follows the same order.
Let’s add a variable to keep track of the calls to useState
. This variable increments every time useState
is called and is used to access the array of hooks
.
// variable to keep track of `useState` calls.
let useStateCallsIndex = -1;
const useState = (initialState) => {
// increment the variable by one
useStateCallsIndex += 1;
// if the hook exists inside the array, let’s return it
if (hooks[useStateCallsIndex]) {
return hooks[useStateCallsIndex];
}
// ...
};
Step 5: Return the Same Hook
There is still one minor issue to fix: the variable useStateCallsIndex
is incremented every time useState
is called:
const useState = (initialState) => {
useStateCallsIndex += 1;
// ...
}
Therefore, it’s never reusing the state because the index is always incrementing. Yet, we want the same index for each useState
call.
This problem has an easy solution: resetting the useStateCallsIndex
value to the initial value “-1” every time we render the app:
const renderApp = () => {
useStateCallsIndex = -1;
const root = document.getElementById("root");
root.innerHTML = "";
render(vDom, root);
};
Step 6: Changing State Value
We haven’t finished implementing the “set state” function. It rerenders the app, but it doesn’t change the state.
(newState) => {
console.log('in da set state');
renderApp();
}];
Where does the state live? It lives inside the global variable hooks
. Therefore, we need access to that state from within the “set state” function.
Let me refactor the code to allow this access.
// we create the array with only one element
const hook = [initialState];
hook[1] = (newState) => {
console.log('in da set state');
// I have access to the old state by accessing the array
console.log("old state:", hook[0]);
renderApp();
}];
hooks.push(hook);
return hook;
Now, we have access to the previous state inside “set state”. Therefore, we can change it:
hook[1] = (newState) => {
// we change the state
hook[0] = newState;
renderApp();
}];
How cool is that! We access another element of the array, from within a function that’s also inside the array 🤯
Boom! Now it works.
Recap: All together
We implemented two new functions:
useState
to manage component state.renderApp
to rerender the whole app.
We changed the following functions:
We added support for click listeners inside the
render
function.We utilized
useState
and theonClick
prop inside theCounter
component.
We also added two global variables to help us manage the state:
hooks
is the array of hooks.useStateCallsIndex
to keep track of the calls touseState
.
With the last six “small” changes, we implemented one of the most powerful features of React: the useState
hook.
After these changes, our counter increments when the user clicks the “+” button.
Gist of our React app in one single file.
Thanks to Sebastia and Bernat for reviewing this article 🙏