React Hooks Under The Hood
October 26, 2024
Introduction
Hooks are a new feature introduced in React 16.8. They let us use React features without writing a class, allow developers to write cleaner, more reusable code.
But as usual, there are many questions surrounding React Hooks. In the official React documentation, there is a specific article discussing the rules for using Hooks, which includes:
- Donβt call Hooks inside loops, conditions, nested functions, or try/catch/finally blocks.
- Only call Hooks at the top level.
So, why are these rules in place? Letβs explore this issue together.
Why can't we call hooks inside loops, conditions, or nested functions?
First, let's take a look at this code snippet:
import { ChangeEvent, useState } from "react";
export default function MyComponent() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
const handleIncreaseCount = () => {
setCount(prevCount => prevCount + step);
};
const handleStepChange = (e: ChangeEvent<HTMLInputElement>) => {
setStep(Number(e.target.value));
};
return (
<div>
<p>Count: {count}</p>
<p>Step: {step}</p>
<input
type="number"
value={step}
onChange={handleStepChange}
min="1"
/>
<button onClick={handleIncreaseCount}>Increase Count</button>
</div>
);
}
In the code above, we use two useState hooks, but we only pass the default values and do not provide any identifying keys for the useState hooks.
So, how does React know which state belongs to which useState?
It's because hooks rely on the call order, if the call order is stable, React can link the state to the corresponding useState.
As long as the call order remains consistent during component render, React can determine which internal state corresponds to which hook. Therefore, if we use hooks inside loops or conditions (like if-else statements), this can change the call order, causing React to lose track of the corresponding state for each hook.
Next, let's implement a basic version of useState and useEffect together to clarify this issue.
Creating our useState hook
Let's create a simple version of React and useState hook to understand how it works under the hood:
const MyReact = () => {
const useState = (initialValue) => {
let state = initialValue; // setting initial value for state
const setterFunction = (newState) => {
state = newState; // setting new value for state
}
return [state, setterFunction];
}
return {
useState
}
}
In the code above, we have created MyReact and useState functions.
MyReactfunction acts as a container for our custom hook. This design mimics the structure of the React library, allowing us to build our own hook functionality.useStatefunction takes an initial value and returns an array containing the current state and a setter function.- Inside
useState, we declare a variablestateand initialize it with theinitialValue. - The
setterFunctionmimics the behavior of ReactsetState, allowing users to update the state value.
Now, it's time to test it out:
const { useState } = MyReact();
const MyComponent = () => {
const [counter, setCounter] = useState(1);
console.log(counter); // log 1
}
MyComponent(); // Initial render of the component
In the code above, we have created a simple component MyComponent that uses our custom useState hook.
When we call MyComponent, it logs the initial value of the counter, which is 1.
Handling state changes
Now let's try changing the state:
const { useState } = MyReact();
const MyComponent = () => {
const [counter, setCounter] = useState(1);
console.log(counter);
if (counter === 1) {
setCounter(2);
}
}
MyComponent(); // Initial render of the component
MyComponent(); // Simulate component re-rendering
In the updated code, we updated the counter to 2, we put the setCounter inside an if statement because otherwise in
a normal React application this would cause an infinite loop.
When we update the state, this will trigger a re-render of the component.
So I simply called MyComponent again to simulate this re-render.
Let's see the output:
1
1
We expected it to log 2 statements: the first with value would be 1, and the second one would be 2. But it logs both statements with the value of 1.
Why is that? The problem is that every time MyComponent is called, it invokes 2 different useState functions,
the value of counter changed only in the function during the first call.
Persisting state across renders
This can be easily fixed, by moving our definition up to keep tracking the state and the changes for it.
const MyReact = () => {
let state;
const useState = (initialValue) => {
if (state === undefined) {
state = initialValue; // setting initial value for state
}
const setterFunction = (newState) => {
state = newState; // setting new value for state
}
return [state, setterFunction];
}
return {
useState
}
}
Let's test it again:
1
2
Now, it logs the expected output. π π π
Handling multiple states
But in a real-world application, we can have multiple states in a component. To do this, we need to update our React like this:
const MyReact = () => {
let state = []; // array to store state values
let index = 0; // index to track the current hook position
const useState = (initialValue) => {
const localHookIndex = index; // store the current index for this hook
index++; // increment index for the next hook call
if (state[localHookIndex] === undefined) {
state[localHookIndex] = initialValue; // setting initial value for state
}
const setterFunction = (newState) => {
state[localHookIndex] = newState; // setting new value for state
}
return [state[localHookIndex], setterFunction];
}
// render function
const render = (component) => {
index = 0; // reset index for re-renders
return component();
}
return {
useState,
render
}
}
In the code above, to handle multiple states, we changed the state variable to an array.
The index variable is used to track the position of the hook in the state array.
Each time useState is called, this index will be incremented to ensure that each call to useState corresponds to a unique index in the state array.
The current value of index is stored in locaHookIndex before it is incremented.
This ensures that in each call to useState, we have the correct index for the current hook and
prevent the setterFunction to use wrong index, because it runs after the useState is called.
Finally, we added a render function to simulate the rendering process of a component.
It resets the index back to 0 each time a component is re-rendered, ensuring that each hook can properly access its respective state value.
Let's add a new state to our component:
const { useState, render } = MyReact();
const MyComponent = () => {
const [counter, setCounter] = useState(1);
const [isSubmit, setIsSubmit] = useState(false);
console.log(counter);
console.log(isSubmit);
if (counter === 1) {
setCounter(2);
}
if (!isSubmit) {
setIsSubmit(true);
}
}
render(MyComponent); // Initial render of the component
render(MyComponent); // Simulate component re-rendering
Output:
1
false
2
true
Our React works as expected, and we can now handle multiple states in a component. π π π
Creating our useEffect hook
To create the useEffect hook, first rename the previous state array to hooks for better understanding,
as this array will now contain the necessary values to work with hooks.
const MyReact = () => {
let hooks = []; // array to store hooks values
let index = 0; // index to track the current hook position
const useState = (initialValue) => {
// ... useState's logic
}
const useEffect = (callback, dependencyArray) => {
let hasChanged = true; // track whether any dependency has changed
const oldDependencies = hooks[index]; // retrieve the old dependencies stored at the current index
if (oldDependencies) {
hasChanged = false;
// check each dependency for changes
for (let i = 0; i < dependencyArray.length; i++) {
const dependency = dependencyArray[i];
const oldDependency = oldDependencies[i];
if (!Object.is(dependency, oldDependency)) {
hasChanged = true;
break;
}
}
}
if (hasChanged) {
callback(); // call the effect if dependencies changed
}
hooks[index] = dependencyArray; // update hooks with new dependencies
index++; // increase index for next hook
}
// render function
const render = (component) => {
index = 0; // reset index for re-renders
return component();
}
return {
useState,
useEffect,
render
}
}
This function accepts a callback and a dependencyArray to manage side effects based on dependency changes.
A hasChanged flag is initialized to track whether any dependencies have changed since the last render.
The previous dependencies for the current hook index are retrieved from the hooks array.
If there are old dependencies, the function iterates through the dependencyArray to check for changes using Object.is().
If any dependency has changed, hasChanged is set to true.
If the dependencies have changed, the provided callback is executed, allowing for side effects to be run.
Finally, the hooks array is updated with the new dependencies, and the index is incremented to prepare for the next hook call.
Let's test the hook:
const { useState, useEffect, render } = MyReact();
const MyComponent = () => {
const [counter, setCounter] = useState(1);
const [isSubmit, setIsSubmit] = useState(false);
useEffect(() => {
console.log('effect');
}, []);
console.log(counter);
console.log(isSubmit);
if (counter === 1) {
setCounter(2);
}
if (!isSubmit) {
setIsSubmit(true);
}
}
render(MyComponent); // Initial render of the component
render(MyComponent); // Simulate component re-rendering
Output:
effect
1
false
2
true
The useEffect hook only runs on the initial render due to its empty dependency array.
Conclusion
This is just a very basic implementation of React, useState and useEffect hooks to help us understand how hooks work under the hood.
The real hooks are more complex, and perform many other things behind the scenes.
To summarize:
- Hooks is just an array, and
Reactaccesses the array using indexes. - If we call hooks inside loops, conditions, those indexes maybe different on every render, and this can cause each of the hook to use wrong state.
Source code for this implementation can be found here.
References
- React Hooks - Official Docs
- Rules of Hooks - Official Docs
- Rules of Hooks - Legacy Docs
- How do React Hooks actually work?
