Universal Hooks

January 19, 2022

If you've studied discrete math, you might be familiar with the concept of a "universal gate." You might have also heard that the NAND gate and NOR gate are each universal gates, since you can create any other gate by composing many NANDs or NORs.

React Hooks compose just like logic gates. If you use multiple hooks, they shouldn't interfere with each other, just as multiple logic gates in a circuit can operate independently. Any collection of Hooks can be abstracted away into a new custom hook, just as a collection of logic gates within a circuit can be abstracted into a single unit.

While we're not going to find a truly universal hook, we will show how a few basic hooks can be used to implement most of the others. We're also not going to aim for complete API compatibility -- each of our hooks will do its best to cover a few common use cases.

This post is intended to be interesting reading for those with some experience with React, and to demonstrate some of the principles of hook composition. Thus, some familiarity with React is assumed.

Link to this section Our building blocks

The React docs list three "Basic Hooks." Let's step through and analyze what each of these do.

Link to this section useState

This hook will keep track of some value between renders. It also provides a function to set the value. Calling this function will trigger a re-render with the updated value.

Link to this section useEffect

This hook will run a function after the first render, before unmounting the component, and whenever one of the provided values changes.

Link to this section useContext

This hook uses the Context API to deeply pass values down the component tree. Since none of the other built-in hooks use the Context API, we won't need to use this.

Link to this section Implementations

I've taken the liberty to rearrange this list a bit from the docs, since we'll use some of the earlier hooks to build some of the later ones.

Link to this section useRef

A Ref can be thought of as a "box" that holds a value. Refs are simply objects of the form:

{
current: [...]
}

This allows you to update the value of the ref, and any code with a reference to it will see the updated value. For instance, consider this code:

let count = 0;
const makePrinter = (count) => () => console.log(count);
const printCount = makePrinter(count);
printCount(); // outputs 0
count += 1;
printCount(); // outputs 0

This is admittedly quite contrived. However, in React, this comes up much more often. If you aren't careful, your side effects, event callbacks, and other functions could close over an old value, and they won't get updated when state changes.

Let's see how a ref can fix this code:

const count = { current: 0 }; // or React.createRef(0);
const makePrinter = (count) => () => console.log(count.current);
const printCount = makePrinter(count);
printCount(); // outputs 0
count.current += 1;
printCount(); // outputs 1

Now's a good time to point out the const. In JavaScript, "constant" is not the same as "immutable." The ref is the same object, it's just mutated.

With this in mind, let's build our hook. We can use useState to keep some state around. However, we don't want to actually set the state at all -- refs don't trigger renders. Our hook can take in some default value, create an object with .current, and use useState to persist it across renders.

const useRef = (defaultValue) => {
const [ref, setRef] = React.useState({ current: defaultValue });
return ref;
};

useRef can also take in a function to create the default value on demand. Thankfully, useState allows this too. We can always call useState with a function argument, and in this function, we can test if the default value provided is itself a function, calling it if necessary.

const useRef = (defaultValue) => {
const [ref, setRef] = React.useState(() => {
if (typeof defaultValue === "function") {
return { current: defaultValue() };
} else {
return { current: defaultValue };
}
});
return ref;
};

Finally, let's finish with an example:

const RefExample = () => {
const messageRef = useRef(null);
React.useEffect(() => {
const interval = setInterval(() => {
if (messageRef.current.style.backgroundColor === "black") {
messageRef.current.style.backgroundColor = "white";
} else {
messageRef.current.style.backgroundColor = "black";
}
}, 500);
return () => clearInterval(interval);
}, [messageRef]);
return <div ref={messageRef}>Hello World!</div>;
};
const App = () => <RefExample />;
ReactDOM.render(<App />, document.getElementById("root"));

Link to this section useReducer

This hook can be thought of primarily as an alternative to useState. A reducer is a function that takes in a state and an action and returns an updated state.

const initial = { count: 0 };
const reducer = (state, action) => {
if (action.type === "increment") {
return { count: state.count + 1 };
} else if (action.type === "decrement") {
return { count: state.count - 1 };
} else if (action.type === "reset") {
return { count: 0 };
} else {
throw new Error("Unsupported reducer action");
}
};

We can use a reducer without React as follows:

let state = { ...initial };
console.log(state); // { count: 0 }
state = reducer(state, { type: "increment" });
console.log(state); // { count: 1 }
state = reducer(state, { type: "increment" });
state = reducer(state, { type: "increment" });
state = reducer(state, { type: "decrement" });
console.log(state); // { count: 2 }
state = reducer(state, { type: "reset" });
console.log(state); // { count: 0 }
reducer(state, { type: "double" }); // Error: Unsupported reducer action

All right, let's look back at React. Here's how you might use useReducer:

const ReducerExample = () => {
const [state, dispatch] = React.useReducer(reducer, initial);
return (
<div>
<span>count is {state.count}</span>
<button onClick={() => dispatch({ type: "increment" })}>increment</button>
<button onClick={() => dispatch({ type: "decrement" })}>decrement</button>
<button onClick={() => dispatch({ type: "reset" })}>reset</button>
</div>
);
};
const App = () => <ReducerExample />;
ReactDOM.render(<App />, document.getElementById("root"));

The useReducer hook returns two values: state and dispatch. The state object is, unsurprisingly, the current state. The dispatch function is a function that calls the reducer with the current state to set the new state. Notably, you don't have to provide the current state to dispatch.

With this in mind, we can start scaffolding the signature of our version of this hook.

const useReducer = (reducer, initial) => {
// ...
return [null, () => {}];
};

We'll need to keep track of the current state somewhere. Let's use the useState hook for this. We'll also need to define some kind of dispatch function.

const useReducer = (reducer, initial) => {
const [state, setState] = React.useState(initial);
const dispatch = React.useCallback(
(action) => {
// do something with the current state
},
[state]
);
return [state, dispatch];
};

Finally, remember that our reducer has signature reducer(state, action). In our dispatch function, we can call our reducer with the current state and provided action, then set the new state to the returned value.

const useReducer = (reducer, initial) => {
const [state, setState] = React.useState(initial);
const dispatch = React.useCallback(
(action) => {
setState(reducer(state, action));
},
[state]
);
return [state, dispatch];
};

One more thing: React's useReducer allows passing in an initializer function as a third argument. We can match this behavior in our hook by passing a function to setState.

const useReducer = (reducer, initial, initFunction) => {
const [state, setState] = React.useState(() => {
if (initFunction) {
return initFunction(initial);
} else {
return initial;
}
});
const dispatch = React.useCallback(
(action) => {
setState(reducer(state, action));
},
[state]
);
return [state, dispatch];
};

And use it like this:

useReducer(reducer, 0, (count) => ({ count }));

Link to this section useMemo

Memoization refers to the practice of making a function "remember" its past inputs and output, so that it doesn't need to execute again if its inputs don't change.

Consider a function like:

const expensive = (x) => {
console.log("executing very expensive computation...");
return Math.exp(x);
};

We can make a new, memoized version that "remembers" its past inputs and output. To associate these inputs and outputs, we can use an immediately-invoked function expression.

const memoized = (() => {
let previousInput = null;
let previousOutput = null;
const expensive = (x) => {
console.log("executing very expensive computation...");
return Math.exp(x);
};
return (x) => {
if (x === previousInput) {
return previousOutput;
} else {
previousInput = x;
previousOutput = expensive(x);
return previousOutput;
}
};
})();

If we try this, we can see that the function is only evaluated when the input changes:

console.log("Not memoized");
console.log(expensive(0)); // executing very expensive computation..., 1
console.log(expensive(0)); // executing very expensive computation..., 1
console.log(expensive(0)); // executing very expensive computation..., 1
console.log(expensive(2)); // executing very expensive computation..., 7.3891
console.log("Memoized");
console.log(memoized(0)); // executing very expensive computation..., 1
console.log(memoized(0)); // 1
console.log(memoized(0)); // 1
console.log(memoized(2)); // executing very expensive computation..., 7.3891

This is the building block we'll need to use for our memoized function.

React's useMemo is a bit different, though. It assumes that the function closes over the values it needs, then uses an extra "dependency array" to determine when changes are necessary. Again, let's start with its signature.

const useMemo = (func, deps) => {
return func();
};

func is the function that we want to memoize, and deps is the array of dependencies. This "works," but doesn't memoize anything.

Since our hook will be called on each render, we'll need a mechanism to keep the old value around. Remember, the whole point of this is to not re-invoke things on every render. We can useRef to keep things around as a ref.

const useMemo = (func, deps) => {
const previousDeps = useRef(deps);
const previousValue = useRef(func);
return func();
};

Here, useRef(deps) returns a ref initialized to the dependency list. useRef(func) takes advantage of the special behavior of useRef when passed a function—it initializes the ref to the output of the function.

Let's compare the previous dependencies with the current dependencies. Object.is() and Array.prototype.every() are both helpful here.

const useMemo = (func, deps) => {
const previousDeps = useRef(deps);
const previousValue = useRef(func);
const matches = deps.every((dep, i) =>
Object.is(dep, previousDeps.current[i])
);
if (!matches) {
previousValue.current = func();
}
return previousValue.current;
};

Let's finish this one off with an example.

const MemoExample = () => {
const [x, setX] = React.useState(0);
const [y, setY] = React.useState(0);
const exp = useMemo(() => {
console.log("Very expensive operation...");
return Math.exp(x);
}, [x]);
return (
<div>
<span>
x is {x}, y is {y}, exponent is {exp}
</span>
<button onClick={() => setX(x + 1)}>increment x</button>
<button onClick={() => setY(y + 1)}>increment y</button>
</div>
);
};

You should see Very expensive operation... logged to the console whenever x changes, but not when y changes. exp should always be kept in sync with x, but the computation is only done when x changes.

Link to this section useCallback

This hook is a variant of useMemo, but it serves a different use case. useMemo is primarily used to prevent extra computation. However, we can also use it to prevent the "identity" of a value from changing.

Conceptually, the output of useMemo changes only when the dependency array changes. This means, if the value is passed down to other components, those components will only re-render when the dependency array changes.

Here's an example (albeit a very contrived one) of this use:

const CallbackExample = () => {
const [x, setX] = React.useState(0);
const [y, setY] = React.useState(0);
const squareX = useMemo(
() => () => {
setX(x * x);
},
[x]
);
return (
<div>
<span>
x is {x}, y is {y}, exponent is {exp}
</span>
<button onClick={() => setX(x + 1)}>increment x</button>
<button onClick={() => setY(y + 1)}>increment y</button>
<ExpensiveComponent squareX={squareX} />
</div>
);
};

This is mostly useful if the function goes into the dependency array of another hook somewhere else.

In this case, there's nothing expensive going on that needs memoizing--the function isn't actually executed anyway. But syntax like () => () => ... is difficult to read and understand. Thus, we can make a version of useMemo that takes in a value directly, instead of a function that returns it.

const useCallback = (callback, deps) => {
return useMemo(() => callback, deps);
};

Link to this section Conclusion

In this post, we've walked through re-implementing a few React hooks by composing existing hooks and adding some logic. With just React.useState, we've implemented useRef, useReducer, useMemo, and useCallback.

Is this practical? Not really. But I hope it gives a sense for what React hooks are "made of," and gives a few examples on how hook composition can work.