Implementing useState directly in vanilla JS, we dig into why functional updates are necessary and why React chose linked lists.

If I were to create a counter in vanilla JavaScript, I would write it like this.
let count = 0;
const addButton = document.getElementById('button');
const countSpan = document.getElementById('count');
addButton.addEventListener('click', () => {
count++;
// You need to update the DOM yourself
countSpan.textContent = count;
});
The screen does not change with just count++. You must explicitly call countSpan.textContent = count.
But in React, you write it like this:
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
Just call setCount and the screen will update automatically. There is no code anywhere that directly manipulates the DOM.
How is this possible?
In this article, we will try to implement useState ourselves and answer two key questions:
If you want to implement useState in JavaScript, there is one thing you need to answer first.
In fact, component functions are executed each time they are rendered, so how is the state maintained?
function Counter() {
let count = 0;
const increment = () => {
count++;
render();
};
}
For example, if written as above, count is initialized to 0 every time the function is executed.
This means that state cannot be placed inside a function.
So what if we put the state outside of the function?
const MyReact = (function () {
let state; // Store outside of function
function useState(initialValue) {
if (state === undefined) {
state = initialValue;
}
const setState = (newValue) => {
state = newValue;
render();
};
return [state, setState];
}
// ...render function
})();
The state is now maintained. But a new problem arises.
function Counter() {
const [count, setCount] = useState(0);
const [name, setName] = useState('React');
const [age, setAge] = useState(20);
}
What happens if you call useState multiple times in one component? As in the code above, if there is only one state variable, all states will be overwritten.
If so, you need a way to distinguish each useState, and there are probably two ways to implement it.
In case 1, this may not be a problem in small apps, but as the number of components and hooks increases, too many variables are used.
Therefore, I used arrangement number 2.
const MyReact = (function () {
let hooks = []; // Store states in an array
let currentIndex = 0; // What is the current useState?
function useState(initialValue) {
const hookIndex = currentIndex; // Capture with Closure
if (hooks[hookIndex] === undefined) {
hooks[hookIndex] = initialValue;
}
const setState = (newValue) => {
hooks[hookIndex] = newValue;
render();
};
currentIndex++; // For the next useState
return [hooks[hookIndex], setState];
}
function render() {
currentIndex = 0; // Initialize each render
// ...
}
})();
Now, if you test the demo, you'll notice that there's a bit of a problem.
The demo is in the form below.
function Counter() {
const [count, setCount] = useState(0);
const incrementThree = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
return `
<div>
<p>Count: ${count}</p>
<button onclick="incrementThree()">+3</button>
</div>
`;
}
In fact, if you look at the code, it's clear why it works this way.
const incrementThree = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
At the time the incrementThree function is executed, count is fixed to 0. (Closure)
This means that each setCount call triggers render(), but does not change the count variable inside the incrementThree function that is already executing.
The official React documentation says that the solution to this problem is to update the state as shown below.
const incrementThree = () => {
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
};
When passing a value, count + 1 is calculated immediately at function execution time.
However, if you pass a function, prev => prev + 1 is executed inside setState and receives the latest value at that point.
Looking at the code implemented above,
const setState = (newValue) => {
// Check if it is a function
if (typeof newValue === 'function') {
hooks[hookIndex] = newValue(hooks[hookIndex]); // Pass latest value
} else {
hooks[hookIndex] = newValue;
}
render();
};
In fact, React’s internal code also follows the same pattern.
// ReactFiberHooks.js
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
return typeof action === 'function' ? action(state) : action;
}
// action is newValue based on our code
// state is hooks[hookIndex] based on our code
The current implementation seems to be working well, but it has some critical issues that make it difficult to use in production.
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
return <Child />;
}
function Child() {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
return <GrandChild />;
}
function GrandChild() {
const [age, setAge] = useState(20);
}
As implemented now, it will be saved as below.
hooks[0] = count // App
hooks[1] = name // App
hooks[2] = title // Child
hooks[3] = description // Child
hooks[4] = age // GrandChild
That is, the states of all components are mixed in one array.
But what happens if you unmount the Child component in this situation?
// After unmounting the child
hooks[0] = count // App
hooks[1] = name // App
hooks[2] = ??? // Empty space?
hooks[3] = ??? // Empty space?
hooks[4] = age // GrandChild - Still in [4]?
React is designed so that each component (Fiber, to be exact) has its own hook-linked list.
If you check the type Hook of ReactFiberHooks.js, next exists.
Fiber(App) {
memoizedState: Hook(count) -> Hook(name) -> null
}
Fiber(Child) {
memoizedState: Hook(title) -> Hook(description) -> null
}
Fiber(GrandChild) {
memoizedState: Hook(age) -> null
}
In other words, even if the child is unmounted, only the corresponding fiber and hook chain need to be removed from memory. Other components are not affected at all.
I implement useState myself and have noticed two important design decisions:
// X setCount(count + 1)
// O setCount(prev => prev + 1)
If you want to explore further Among React source code,
packages/react-reconciler/src/ReactFiberHooks.js
basicStateReducer - Functional update logic
updateWorkInProgressHook - Linked list traversal
mountState vs updateState - step by step
You can refer to this route.