The Gist of Hooks
Many more words about React.js. Previously: The Zen of React.
As you may know, in 2018ish, React added Hooks to the library, presenting them as a new, better thing which should replace all your old class components. This was very strange and controversial at the time. Still is, judging by the comment section on Hacker News every other week. Among other things, complaints about hooks are: they’re confusing, they’re clunky, they’re unnecessary, they’re difficult to use correctly. All of these are true.
But hooks are, in fact, the best. They’re the future of programming. This article, hopefully one of a series, is about getting you to agree. It’s about what hooks are and why they are how they are.
1. What are hooks?
A little summary, to catch everyone up.
A modern React class component, the thing we had before hooks, basically looks like this (in Typescript because we’re not barbarians):
type CounterProps = {
initialValue: number,
label: string;
};
type CounterState = {
value: number;
};
class Counter extends React.Component<CounterProps, CounterState> {
constructor(props) {
super(props);
this.state = {
value: props.initialValue
};
}
updateCounter() {
this.setState({value: this.props.value + 1});
}
render() {
return (
<div class={counter}>
<div class={button} onClick={this.updateCounter}>
{this.props.label}
</div>
{"Value = " + this.state.value}
</div>
);
}
}
This defines a Counter
class which, when mounted, initializes with a given value, and then each time a button on it is clicked, increments that value by one.
React promises you that the component will be re-updated (‘reconciled’) on screen whenever the props
or state
changes. Here, state
changes via setState()
in response to the button being pressed, and props
can change whenever and it will re-render with the current value of label
. Changes to initialValue
happen not to do anything, because it’s only used in the constructor, but that’s totally an implementation detail of this class.
Hooks are another way of doing all of this: instead of writing classes with methods on them that React calls “in to”, write functions with hooks that call “out to” React. The same component looks like this with hooks:
const Counter: React.FC<CounterProps> = React.memo(({initialValue, label}) => {
const [value, setValue] = React.useState(initialValue);
const updateValue = React.useCallback(() => setValue(value => value + 1), []);
return (
<div class={counter}>
<div class={button} onClick={updateValue}>
{label}
</div>
{"Value = " + value}
</div>
);
});
It’s a bit shorter! But it’s basically totally equivalent. It’s just… weird, right? Yeah. A bit. So why would you want to do this?
Well, basically because classes suck.
2. What’s wrong with classes?
The core idea of React is that a component will be (semi-)efficiently re-run and written to the DOM whenever the props
or state
change. There are also some other requirements: some escape hatches to do things before/after renders, and to jump out into regular JS that React doesn’t manage.
One obvious way to do this is have a class that takes props
as arguments, and then owns its own state
. So that’s what React did, and they let you update that state with a setState
method that you can use wherever to trigger a re-render. There are also some lifecycle methods (componentDidUpdate
, etc) that let you do the other stuff.
But it didn’t have to be this way. It’s just one implementation of the requirements of a “component”. It was the 2010s, everyone still thought object-orientation was somehow fundamental to programming, weird stuff happened, and React was built on classes. But any way of getting these requirements would do. Hooks are just another way of getting them, which turns out to be better.
The main problem with classes is just that they’re a weird meme from the 90s that never paid enough rent to stay around as long as it did. Now we basically know, intuitively, that objects should either own state or contain business logic, but probably not both. There are probably a bunch of other ways of structuring programs that didn’t win out, historically, but are just as good.
Javascript’s version of classes in particular sucks, due to two obvious problems. One is that the spread operation {...object}
doesn’t work on classes, because it only works on fields and arrow functions (defined on an object itself) but not on methods (!) or static fields, because those are defined on the prototype. This means that either spreads and classes shouldn’t exist in the same language, or, they shouldn’t work together. But they do; they just give the wrong answer. The other issue is, of course, the traumatic experience of this
, and the need for .bind(this)
all over the place if you ever actually use methods.
Presumably the reason that classes were appealing to early React is that they sorta specify a ‘type’ for a component. There’s a list of abstract methods that you can implement. Fine, another side-effect of the neverending farce that is languages without static typing.
But classes also specifically suck as a way of implementing components. Because classes provide access to the component lifecycle only through inheritance, there’s this thing I call the “don’t call us, we’ll call you” problem. Since React is going to invoke your lifecycles methods for you to do anything, you have to implement each of them in a single place that does everything at once, even if this is not how your component’s behavior is logically separated.
The only real ways of re-using behavior ergonomically were higher-order components (HOCs) or equivalents, such as subclassing React.Component
yourself. Neither works well: composing abstractions involves layering complexity (a(b(c(d(e))))
) when all you actually need is linear complexity (a(); b(); c(); d(); e();
). Both are also hell to deal with in Typescript, although that’s not, necessarily, React’s problem.
Really seeing that hooks are good is just about letting go of the idea that there’s something fundamental about classes. Classes are just a thing someone made up. They’re pretty shitty. So one should at least entertain alternatives. And then the first time you need a component to do more than two things to manage itself and you can just call into more hooks to do it, you’ll be sold. I don’t think hooks are what programming is going to look like in fifty years, but it’s going to look more like hooks than classes. Sheesh.
3. How hooks work
It can be very instructive to see an explicit implementation of a hook written out as though it was all application code. I wish this was done in the React docs.
Here we’ll sketch out the backend for a simple useState()
hook, to illustrate how it works. This example code is not literally how they’re implemented; I haven’t gone to look how they’re actually implemented at all. But it is, in some sense, isomorphic to it, and it’s a plenty-good mental model for working with hooks in your day-to-day.
Hooks are implemented by setting global state before running a component and unsetting it afterwards, such that each component gets access to some local state without having to actually make a closure over it. Like so:
type HookState = {
type: string,
data: any,
};
type ComponentState<Props> = {
// whether this component needs re-rendering in the next pass
// nb: this is not how React actually does it
dirty: boolean,
hooks: HookState[],
isMounting: boolean,
currentHook: number,
}
let markComponentForRerender = (component: ComponentState) => {
/* some React-provided function that we don't worry about */
}
// set by the renderer before a component is executed.
let currentComponent: ComponentState;
/*
All useState does is: save some stuff into the hooks array,
and optionally mark us for re-render if needed, using a
behind-the-scene magic React function.
*/
function useState<T>(initialValue: T) {
const component = currentComponent;
if (component.isMounting) {
const data = [initialValue, (value) => {
if (value !== data[0]) {
data[0] = value;
// rerender this component on the next render pass
// don't worry about how this works. just trust that React does it.
markComponentForRerender(component);
}
];
hooks.push({
type: 'useState',
data: data,
});
} else {
// sanity checks, aka the Rule of Hooks
assert(component.hooks.length > component.currentHook);
const currentHook = component.hooks[component.currentHook++];
assert(currentHook.type === 'useState');
return currentHook.data;
}
}
So that’s how useState
works, in principle. It’s pretty easy to implement useMemo
or useRef
the same way (in fact they are simpler because they can’t trigger re-renders). You can pretty much do useEffect
if you assume the existence of a black-box scheduleAfterRender()
function as well. Never mind how React actually implements these signals: that’s part of the ecosystem. You could probably throw something together that gets the basics working without too much trouble.
The point is, hooks are not that complicated. They’re a bit strange because they’re treated as magic functions that obey strange rules, when in fact they are regular functions, and the only sense they have strange rules are that they make certain assumptions about when they’re called. Unfortunately you don’t ever see this written out or made very explicit, so you have to imagine it yourself while you’re learning the ropes.
But, alas, hooks do also have problems! More on those next time. Then maybe I’ll try to convince you that they’re good anyway, despite all the problems.
My articles about React: