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.
