Back

Hooks vs Classes a few months later

📖 tl;dr: Hooks have simplified a lot of UI code for many users. But what's the maintainers perspective on them? Do they lead to less time spent answering support questions?

It's been a while now that the hooks concept took over the frontend world by storm. Originally researched by the very talented React team it solves some longstanding issues surrounding the best way to make behaviours easily composable and shareable. A lot has been written about what they are and how to use them, so I won't repeat that here. Instead I'd love to share the maintainers perspective on them.

Once we announced our first Preact X alpha many users immediately jumped on the hooks bandwaggon, and for good reasons! Since that release we've received significant less bug reports and support questions on slack regarding the use of components. With hooks there simply aren't many possiblities to tread down the wrong path anymore. It's a solid API that in my experience is even easier to understand for newcomers, which is always a plus!

So what are the footguns users run into with the Class-API you ask? For that I collected some of the more frequent snippets we received in the last months.

Initializing state is confusing

This is something that trips up people coming from different languages a lot. You know in most OOP-based programming languages there is the concept of setters. It's arguably less common in the JavaScript world, but one can still find it from time to time.

The idea is that you don't modify property directly and instead opt to set them using special setter functions. This allows the underlying class to change/rename the variables under the hood, without the fear of breaking userland code. Because of that you never set the properties directly and the natural conclusion would be to write something like this to initialize the state of a component:

class Foo extends Component {
constructor(props) {
super(props);

// Don't do this.
this.setState({
foo: 20
});
}
}

Thing is that this is effectively a no-op. It doesn't initialize the state. Instead we deviate from the common getter/setter concept and set it directly:

class Foo extends Component {
constructor(props) {
super(props);

this.state = {
foo: 20
};
}
}

This is, again, another think we have to consciously keep in mind. It's something newcomers seem to run into once in a while. With hooks we can sidestep that completely, because they're just a function call. We just pass the initial state upon calling it:

function Foo() {
const [foo, setFoo] = useState(20);
//...snip
}

Nested setState calls

After pondering about a use case for a while, I still have no idea where one would want to use nested setState calls. A while ago we received a bug report that had code similar to this:

// Don't do this
this.setState({ foo: 3 }, () => {
this.setState({ bar: 10 }, () => {
this.setState({ bob: 20 }, () => {
//...
});
});
});

The second argument of the setState function is a callback that will be called when the state is updated. It's super rare in the real-world, and I personally think this is in hindsight a mistake. This pattern is bad for performance, because now you're rendering the component 3 times instead of once.

So what is this code trying to do? What makes it so important to differentiate the second from the third render? If you know a valid use case, please message me. I'd love to know more!

Lifecycles were a bit messy

One tricky thing with the old lifecycle API was the way they were called. Imagine a scenario where you want to execute something whenever your component is mounted or updates. Like a tooltip where you need to position it yourself via JS to know if there is enough space to show it above a button. Otherwise the tooltip should be moved below the button. It's a super common thing but back than we had to split it up into componentDidMount and componentDidUpdate. Naturally the pattern that emerged there was that both cDM and cDU lifecycle hooks just called another method.

class Tooltip extends Component {
componentDidMount() {
this.measurePosition();
}

componentDidUpdate() {
this.measurePosition();
}

measurePosition() {
// do some measurements
}
}

Now compare the above to the hooks version:

function Tooltip() {
useEffect(() => {
// do measurements here
});
}

But the real trouble happens behind the scenes. To successfully support class-based Lifecycles we have to be extremely careful with the arguments. We have to always drag the previous props and state around and be super careful when calling lifecycles that can change them like getDerivedStateFromProps.

The question that pops to mind is: "Why do we even have to do that?" And the reason is that both props and state are reference bound. When we update one of them we set this.props or this.state to the new value and thus losing the reference to previous one. To prevent that we store it the previous version in a variable just before updating it.

// Pseudo code, we don't assign `this.state` synchronously
class Component {
setState(state) {
this.prevState = this.state;
this.state = state;
enqueueRender(this);
}
}

// later during render
let inst = MyComponentInstance;
inst.shouldComponentUpdate(inst.nextProps, inst.nextState);

Again, in hooks-land this becomes a lot easier because we can use plain JS to take advantage of closures. We don't need to store the new state immediately on our component instance and can defer that to when the component has completed the diffing phase. That's another case where we can save a few bytes!

Less footguns = less maintenance

At this point one can argue that we could at more checks in preact/debug or create a linter preset to warn about common misuses of the Class-API. Have no doubt, this would work and help our users a lot. But there is a hidden cost for us as maintainers in that we have to write code especially for debugging purposes. On top of that it's another thing that needs to be documented, throughouly tested and so on...

Instead it's much more desireable for us to have a solid API which doesn't allow these issues to pop up in the first place. With something like that we can save us a lot of work, that we can use to work on nextgen features.

Are hooks the perfect API? I don't think so, but it's one of thos that has gotten us the closest so far. It definitely frees us maintainers a lot of time spent answering questions. I'm excited to see where the future will lead us, even more so that more frameworks are much more willing to experiment with new APIs than just a few years before.