To better understand how hooks work in React, I set out to try and implement a simplified replica. At the heart of it lies one of the most fundamental features of Javascript, closures, an introduction to which serves as an appropriate prelude to this article.
What are closures?
As defined by MDN:
A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment).
Best explained with the help of a simple example
// A call to makeAdder returns a function that has access to the argument x throughout it's lifespan.
const makeAdder = (x) => (y) => x+y;
const adder1 = makeAdder(1);
const adder2 = makeAdder(2);
console.log(adder1(10)); // 11
console.log(adder2(10)); // 12
Closures enable functions to have access to private variables.
Implementing useState
Let's look at the basic interface for useState
namespace React {
/**
* @param initialValue the initial value
* @returns a stateful value and a function to update it
*/
export interface useState {
<T>(initialValue: T): [T, (value: T) => void];
}
}
}
From the initial introduction to closure, we can deduce that useState
employs a private variable to maintain our state value and exposes a function to update it.
Tracking a single state value
let stateValue;
const useState: React.useState = <T>(
initialValue: T
): [T, (currentValue: T) => void] => {
stateValue = stateValue ?? initialValue;
return [
stateValue,
(newValue: T) => {
stateValue = newValue;
},
];
};
const Component = () => {
const [count, setCount] = useState(0);
return {
logState() {
console.log(`Count: ${count}`);
},
click() {
setCount(count + 1);
},
};
};
let element = Component();
element.logState(); // Count: 0
element.click(); // Increment count;
element = Component();
element.logState(); // Count: 1
element.logState(); // Count: 1
The above is the most basic implementation of useState
which keeps track of our one state value in a global variable. Ideally, we want all data related to React, including our state variable to be encapsulated within our library to prevent it from any unintended manipulation, which we can achieve using an IIFE expression as you will see in the next section.
Tracking multiple state values
Now what happens if we need to track multiple state values instead of just one? We need to store a collection of state values in an array instead.
// Encapsulating all values and methods for React with this IIFE.
const React = (function () {
let stateValues = [];
let hooksIndex = 0;
const useState: React.useState = <T>(
initialValue: T
): [T, (currentValue: T) => void] => {
// Increment the hooks index everytime.
const _locallyScopedIndex = hooksIndex++;
const stateValue = (stateValues[_locallyScopedIndex] as T) ?? initialValue;
return [
stateValue,
(newValue: T) => {
stateValues[_locallyScopedIndex] = newValue;
},
];
};
/**
* We shift the responsibility of rendering our component to React,
* so it can reset the hooksIndex to 0 everytime our application is rendered.
*/
const render = (component) => {
hooksIndex = 0;
return component();
};
return {
useState,
render,
};
})();
const Component = () => {
const [count, setCount] = React.useState(0);
const [name, setName] = React.useState("foo");
return {
logState() {
console.log(`Count: ${count} & Name: ${name}`);
},
click() {
setCount(count + 1);
},
type(newName: string) {
setName(newName);
},
};
};
let element = React.render(Component);
element.logState(); // Count: 0 & Name: foo
element.click(); // Increment count;
element = React.render(Component);
element.logState(); // Count: 1 & Name: foo
element.type("bar"); // Change name
element = React.render(Component);
element.logState(); // Count: 1 & Name: bar
Some important characteristics of React hooks that become evident from the implementation above:
-
Every invocation of a hook is assigned an address,
hooksIndex
in our case. Hooks must be invoked in the same sequence for each render cycle to ensure they have access to the correct address assigned to them, which enforces the following Rule of Hooks:
Don’t call Hooks inside loops, conditions, or nested functions. Instead, always use Hooks at the top level of your React function, before any early returns. -
The
set
function only updates the state variable for the next render. If you read the state variable after calling theset
function, you will still get the old value that was on the screen before your call.
Implementing useEffect
namespace React {
...
/**
* @param cb the callback to run when dependencies change.
* @param dependencies a list of dependencies that determine if React should run our effect.
*/
export interface useEffect {
(cb: () => void, dependencies?: StateValues): void;
}
...
}
useEffect
builds on top of useState
essentially storing the dependencies as an application state.
const React = (function () {
...
const useEffect: React.useEffect = (cb, dependencies) => {
const oldDependencies = stateValues[hooksIndex] as
| Array<unknown>
| undefined;
if (
// Run the effect every time if dependencies are undefined
// or only once, if dependencies is an empty array.
!oldDependencies ||
// Check if any of the values in the dependencies array has changed
(dependencies as Array<unknown>).some(
(val, index) => !Object.is(val, oldDependencies[index])
)
) {
cb();
}
// Increment hook index and update the dependencies
stateValues[hooksIndex++] = dependencies;
};
...
})();
const Component: React.FC = () => {
const [count, setCount] = React.useState(0);
const [name, setName] = React.useState("foo");
// Run the effect every time `name` changes.
React.useEffect(() => {
console.log(`Name changed to: ${name}`);
}, [name]);
return {
...
};
};
let element = React.render(Component);
element.logState();
element.click();
element = React.render(Component);
element.logState();
element.type("bar");
element = React.render(Component);
element.logState();
// Outputs:
// Name changed to: foo
// Count: 0 & Name: foo
// Count: 1 & Name: foo
// Name changed to: bar
// Count: 1 & Name: bar
Our effect runs only when the name
state changes as expected. The implementation above uses Object.is
to compare elements of the dependencies which is also true for React.
Also worth noting is one important anomaly in our implementation. We are running our effect as soon as the useEffect
hook is invoked, while in React,
..effects defined with
useEffect
are invoked after render. To be more specific, it runs both after the first render and after every update. In contrast to lifecycle methods, effects don’t block the UI because they run asynchronously.
Customs hooks
Interestingly, at this time, we can extract our stateful logic into a custom hook and it should work as expected.
const useCustomHook = () => {
const [count, setCount] = React.useState(0);
const [name, setName] = React.useState("foo");
React.useEffect(() => {
console.log(`Name changed to: ${name}`);
}, [name]);
return {
count,
setCount,
name,
setName,
};
};
const Component = () => {
const { count, setCount, name, setName } = useCustomHook();
return {
...
};
};
...
Exercise for the reader
Try recreating the useMemo
and useCallback
hooks. You can check my implementation here,
Hope you enjoyed the read.
✌🏾