The Gist of Hooks

[October 12, 2022]

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 the 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 nevertheless I think hooks are in fact the best, and they’re the future of programming. I wanted to write an article is about getting you to believe me and agree that we should forgive their flaws. Then it started to get very long, so it has become several articles. This is the first one. It’s about what hooks are and how they work.


1. What are hooks?

I’m generally assuming you already know a bit about React and hooks… but whether you do or not, I’m going to give a little summary, to catch everyone up.

A (modern) React class component, the thing that we used 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, written using hooks, looks like this:

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?


2. Why not classes?

Well, why 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.

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.

Besides setState, the React.Component superclass also provides a bunch of methods that you can use to hook into React’s runtime: componentWillMount, componentDidMount, componentWillUpdate, componentDidUpdate, componentWillUnmount, and also some nonsense ones we won’t mention here. These let you run code at important times in the lifecycle: before/after the mount (initial render), before/after every render, and before (but not after, lol) unmounting. These are useful times for, for instance, kicking off network requests to fetch data, or making imperative updates to the UI for whatever reason.

But it didn’t have to be this way. It’s just one implementation of the requirements of a “component”.

What is a component, really? Regardless of the syntax or calling semantics, it’s:

  • A render() function which defines some JSX in response to passed-in props and local state.
  • a way to update that state
  • plus the ability to do things before/after renders
  • plus escape hatches to do whatever other Javascript stuff you could possibly want that React doesn’t want to know about.
  • a way of not updating the DOM if the return value hasn’t changed
  • and ideally a way to not not rebuild children if their props haven’t changed (although you can always implemented that yourself like React.memo() does)

And any way of getting these requirements would do. Hooks are just another way of getting them.

Why would you want another way, besides classes? Well, the short version is: “classes suck”. The longer version is this:

  1. Classes suck.
    • classes and OOP in general were a meme from the 90s that went way too far and made a lot of code vastly more confusing than it ever needed to be.
      • I do believe this, but that doesn’t mean I think we should all be using Lisp or Haskell. I just think OOP and classes are kinda bad.
    • the main lesson of programming since the 90s was, in my opinion: objects should either own state or contain business logic, but should very rarely do both at once.
    • classes were especially hard to shake, though, because type systems were so weak that it was your only real option for ensuring that code ran on correctly-structured bundles of state.
    • but it’s 2016 or 2022 or whatever it is, now, and now, we have Typescript, whose basically weak and bad type system is nevertheless infinitely better than, like, Java.
      • so let’s move on already.
  2. JS classes in particular suck, because they were glued on top of prototypical inheritance and are just terribly awkward.
    • For instance in JS you can’t spread an instance of a class to make a copy of it: {...someInstance} makes an object with all the fields and arrow functions, but not the methods (!) or static fields of someInstance, because those are on the prototype. How horrible.
    • Which means that if you have two objects that implement the same interface, spreading will sometimes work and sometimes won’t, depending on how they were built. Ew.
    • (and for the record, prototypical inheritance was probably a mistake anyway)
    • Not to mention the trauma of this
      • and the need for .bind(this) on every React component class method, to the chagrin of every React novice
    • (Not that there’s much need to bash on JS, everyone else already did that. But .bind() and class-spreading aren’t really fixable at this point, unlike other stuff that is being fixed (hoisting and var vs const, for...of, use strict, etc)
    • Even if those weren’t issues, I really can’t see why JS needed classes (besides ‘for the meme’) when it already had objects with closures.
      • … except, well, that it was really all to work around the fact that it didn’t have types to define many objects with the same fields and functions.
      • … how unfortunate.
  3. In particular, classes suck as a way of implementing components.
    • Because classes provide access to the component lifecycle only through inheritance, there’s this “don’t call us, we’ll call you” problem: since React is going to invoke your lifecycles for you, you have to define the full implementation in one place, so all of the behavior has to be combined together into the one implementation. This makes composing abstractions really annoying.
    • The only real ways of re-using behavior ergonomically were higher-order components (HOCs) or equivalents, such as subclassing React.Component yourself.
    • Both of these meant that using abstractions involved writing a(b(c(d(e)))) when you would like to just write a(); b(); c(); d(); e();: you were forced to contend with layered complexity when the thing you were dealing with only called for linear complexity.
      • for instance, subscribing to contexts
      • or adding utilities that way for network requests and converting them to props
    • The same problem applied to state: a component could only have one state type, so it couldn’t compose anything that had its own state without wrapping it (or being wrapped by it), meaning, once again, layered instead of linear complexity.
    • not to mention, typing HOCs in Typescript was just a disaster.
    • also, it was always weird that, in TS, class components were generic on the type of their state, which was otherwise totally private to users … but they had to be in order for setState() and this.state to be properly typed.
  4. There exists a better way that sucks less, which is function components with hooks for state and side-effects.

So that’s why we ended up with hooks. (I mean, I assume. I don’t know anything about React’s actual decision-making process.) Okay, that was maybe more of an unhinged rant than a super compelling argument, but it at least explains why you would start looking for a way to avoid classes. In a later post I’m planning to talk a lot more about why you would go from “I guess we can use hooks” to “yes, let’s definitely use hooks”.


3. How hooks work

It can be useful 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 dealing with it 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;
    }
}

That’s all. 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). And you can pretty much do useEffect if you assume the existence of a black-box scheduleAfterRender() function.

The point is, it is not that complicated; it’s just a bit strange because 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.


4. How to invent hooks

… Just for fun, here’s how you would invent hooks.

A class is really just a bundle of methods that close (in the ‘closure’ sense) over the same data. That is, a class:

class Foo {
    constructor(args) {
        this.args = args;
        // whatever construction stuff you want to do.
    }

    // whatever private method definitions

    method() {
        console.log(args);
    }
}

Is essentially the same as a function that returns an object whose fields are closures:

function Foo(args) {
    // whatever construction stuff you want to do.
    // whatever private method definitions

    return {
        method: {
            console.log(args;
        }
    };
}

(In fact they are so similar that I am disappointed JS felt like it needed to add classes at all instead of maybe just making this syntax a little more ergonomic. I guess there is a tiny performance enhancement available by being able to reuse the same method definitions from the prototype, but it is hard to care about and probably not worth it — especially considering how much of a landmine this and .bind() are, as well as the nonsense with spreading classes that I mentioned earlier.)

So, React could have implemented stateful function components like that, because they’re basically the same thing as classes. They would have looked like this:

const Counter = ({initialValue, rerender) => {

    // section which is equivalent to the constructor + private method definitions
    const state = initialValue;
    const update = () => {
        state = state + 1;
        // some React-provided function for triggering re-renders 
        // for the current component
        rerender();
    }

    // section which is equivalent to render() + extension methods
    return () => ({
        render: (props) => {
            // some counter implementation that calls update()
        },
        componentWillUpdate: () => {},
        componentDidUpdate: () => {}, // etc
    });
}

But once you see it written like this, it’s pretty clear that you can make it more ergonomic. If you’re providing rerender() why not provide some other functions, like scheduleBeforeUpdate and scheduleAfterUpdate? This solves the “don’t call us, we’ll call you” problem: now you can call (or not call!) the lifecycle methods as many times as you want, from wherever you want, instead of having the only point for plugging into those lifecyles be those specific abstract method implementations. It lets you abstract out behavior that interacts with the lifecycles.

(This is a general lesson that can be learned from every bad framework of the last two decades: frameworks like React are basically runtimes, and fundamentally there is freedom in a runtime’s API for what semantics you will use to tell the runtime what it should do. There are all kinds of ways of doing this:

  • abstract methods on classes
  • explicit function calls (like hooks)
  • @decorator-based discovery
  • discovery based on, like, detecting functions in the global scope (c.f. aspect-oriented programming)
  • special syntaxes for writing directives in comments
  • external DSL-based definitions (a la XML)

The lesson that everyone eventually learns is always the same: quit being clever. Just let people use your framework from regular code, via regular function calls, that a regular debugger works on. Everything else is comparatively just (a) weaker and (b) more confusing. Function calls already allow all of the utilities of abstraction you could ever want; there is simply no reason to do something that is equivalent but less generalizable.)

So we’re going to replace ‘abstract methods as extension points’ with ‘function calls as extension points’. While we’re at it we can also abstract state: since every state update is going to end up wanting to invoke rerender() in the exactly same way, we’ll make a function called useState that does both at once.

const Counter = ({
    useState, 
    scheduleBeforeUpdate, 
    scheduleAfterUpdate
) => {
    return ({initialValue, ...otherProps}) => ({
        const [value, setValue] = useState(initialValue);

        // now you can call these as many times as you want
        // instead of having only one definition
        // which solves the 'linear composition' problem
        scheduleBeforeUpdate( /* ... */ );
        scheduleAfterUpdate( /* ... */ );

        // now we can do this, because we get to decide when these are called
        callSomeAbstraction(scheduleBeforeUpdate, scheduleAfterUpdate);

        return <div/>; // whatever
    });
}

Now that everything is injected, we can make one more change to remove the outer function entirely. Since JS is totally single-threaded, if we set a global variable before calling a function and unset it afterwards, it’s equivalent to the function being a closure over that value. Actually, it’s a bit better, because you don’t have to pass the lifecycle methods around if you want to use them inside of some piece of folded-in abstraction.

const Counter = ({initialValue}) => ({

    // these library functions are set before we're invoked
    // which makes this equivalent to the above
    const [value, setValue] = useState(initialValue);
    scheduleBeforeUpdate( /* ... */ );
    scheduleAfterUpdate( /* ... */ );

    // no args necessary!
    callSomeAbstraction();

    return <div/>; // whatever
});

Voilà! We’ve re-invented hooks.

Of course, scheduleAfterUpdate and such were not the names they come up with. They instead called that one useEffect, and they removed scheduling things before updates entirely (except for cleanup methods that are returned from useEffect). They also decided to standardize all of these “global-scope utility methods” to be called use*, and came up with some rules about how to use them.

The point is, hooks aren’t that different from classes. The calling semantics are different, but the only real difference — besides some miscellaneous design decisions to clean up past mistakes (such as distinguishing between mount and update) — was in allowing you to use explicit function calls instead of abstract method implementations as extension points.

But, alas, hooks do also have problems! So many problems. More on those next time. Then I’ll try to convince you that they’re good anyway, despite all the problems.