React hooks have been introduced in React 16.8. Their aim is described on
the react documentation:
They let you use state and other React features without writing a class.
Quite soon after the announcement I had a look at them, and I was running through the following cycle since then:
- I disliked hooks, mainly because they were new and it's probably people's nature to be skeptical about new things, although most developers (including me) are probably too skeptical.
- Then I got used to them and started to like them because they have certain advantages.
- Now I am not that fond of them anymore, since I ran into some unexpected problems and realized that I have a hard time explaining them in my university courses to students.
I could imagine that there are some people out there that ran through the same stages. In this blog post, I want to
explain my reasoning.
Positive aspects of hooks
Skipping my first phase of disliking hooks (which, as usual, is a bit uncalled for) I would like to explain some of the
reasons I like them after I got used to them.
Hooks compose nicely
What is objectively nice about hooks is that they compose in a very nice way. There are some prebuilt hooks
(useState
and useEffect
are probably the most prominent ones), that can be easily used in hooks built in a custom way for projects, and a
custom hook is just another function that might reuse existing hooks. A simple made-up scenario would be a custom hook,
that also returns the doubled value of a state, which could be implemented because the doubled value is required quite
often in a web application (for whatever reason).
function useStateWithDouble(initialValue) {
const [value, setValue] = useState(initialValue);
return [value, setValue, value * 2];
}
This useStateWithDouble
hook returns not only the value itself and a function to set it, but also the doubled value,
which could then be easily used in a react component.
function App() {
const [value, setValue, doubledValue] = useStateWithDouble(0);
return (
<>
<input
onChange={(event) => setValue(event.target.value)}
type="number"
value={value}
/>
<p>Value: {value}</p>
<p>Doubled value: {doubledValue}</p>
</>
);
}
There is no real limit on what can be done within such a custom hook, it is also possible to mix many different calls
to the useState
, useEffect
, and all the other hooks, a custom hook can even reuse another custom hook. This allows
for very easy code reuse among different components.
useEffect
is really nice
Another thing I like is the idea of the useEffect
hook (although I think hooks are not absolutely necessary for that
idea). Previously lifecycle
methods had to be used instead.
They allowed to execute code when e.g. the component was mounted into or unmounted from the DOM, but the problem was
that code that actually belongs together was split. E.g. if a component that counts the elapsed seconds was developed,
then a setInterval
was started when the component mounted and clearInterval
was called when the component was
unmounted. This is exactly what the componentDidMount
and componentWillUnmount
functions in the code below are
doing.
class App extends React.Component {
constructor() {
super();
this.state = {
seconds: 0,
}
}
componentDidMount() {
this.interval = setInterval(() => {
this.setState({
seconds: this.state.seconds + 1,
});
}, 1000);
}
componentWillUnmount() {
clearInterval(this.interval);
}
render() {
return <p>{this.state.seconds}s ellapsed!</p>;
}
}
The code for the interval is split among these two lifecycle functions, which is already bad enough, but it gets even
worse when there is more than one piece of code that needs a setup like this because then the componentDidMount
and
componentWillUnmount
functions do not follow the single responsibility
principle anymore. The following code shows an example
of that by also counting minutes.
class App extends React.Component {
constructor() {
super();
this.state = {
seconds: 0,
minutes: 0,
}
}
componentDidMount() {
this.secondsInterval = setInterval(() => {
this.setState({
seconds: (this.state.seconds + 1) % 60,
});
}, 1000);
this.minutesInterval = setInterval(() => {
this.setState({
minutes: this.state.minutes + 1,
});
}, 60000);
}
componentWillUnmount() {
clearInterval(this.secondsInterval);
clearInterval(this.minutesInterval);
}
render() {
const {minutes, seconds} = this.state;
return <p>{minutes}m and {seconds}s ellapsed!</p>;
}
}
The same code can be implemented a lot cleaner using the useEffect
hook. The effect can return a function, which is
the cleanup function. This way the code that belongs together also is also colocated in the source, like the
setInterval
and clearInterval
calls in the code below.
function App() {
const [seconds, setSeconds] = useState(0);
const [minutes, setMinutes] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds((seconds) => (seconds + 1) % 60);
}, 1000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
const interval = setInterval(() => {
setMinutes((minutes) => minutes + 1);
}, 60000);
return () => clearInterval(interval);
}, []);
return (
<p>{minutes}m and {seconds}s ellapsed!</p>
);
}
Negative aspects of hooks
Let us switch to the negative parts of hooks that made me at least like them a lot less than after the first honeymoon
phase. There are workarounds for some of these issues, but they make some concepts hard to explain, and in my
opinion, this is a sign that not everything is solved in the best possible way, and that there might be some underlying
issues.
To me, it feels like the authors of react dislike classes too much. Sure, there are some issues with classes in
JavaScript, the most prominent example is the binding of this
, which sometimes
behaves differently than in other object-oriented languages. But using class
properties worked quite well, so this is not really
an argument for me. And this hatred against classes leads me to my first point.
Functions do not simply map input to output anymore
I think they have worked too hard to get rid of classes, up to a state that they were willing to overlook some serious
downsides of the hooks approach. But most importantly, this decision broke a very important invariant of functions,
namely that a function will return the same value if the same input parameters are passed. The main reason for that
is the useState
hook.
export default function App() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
}
The above code shows what I mean by that. Whenever the button
has to be rerendered, the function is executed again.
But even though in all cases the function is called without any arguments, there is no clear mapping to the output,
the output is different every time instead. useState
introduces some side effects, something that has been frowned
upon, especially in functional programming, because it makes hunting bugs harder. It is not possible to tell if the
component is working just by calling it with some parameters because it now has some internal state. Sure, that is
also a downside of object-oriented programming, but it is expected in object-oriented programming, for functions not so
much. So I think that stuff having an internal state should be modeled using classes and objects instead of
functions.
Additionally, there are many hooks like useState
, useRef
, or useMemo
that seem to replicate the behavior that
would be quite easy to be implemented in classes, which makes this decision even less understandable for me.
useState
introduces staleness
Another issue is that hooks avoid the this
keyword, but introduce another problem called staleness. This is
demonstrated in the below (not functional) example.
function App() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds(seconds + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return <p>{seconds} seconds ellapsed!</p>;
}
The above example will only count to 1 and seems to stop then. The issue is that the seconds
variable in the
useEffect
hook will always be taken from the first render, where seconds
was set to 0
because this is how
closures work. Therefore the setSeconds
called
every second will always calculate 0 + 1
and assign 1 to the state, causing the seconds to hang from an end-user
perspective. This issue can be fixed by passing a function to setSeconds
, which will retrieve the current value as an
argument.
function App() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds((seconds) => seconds + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return <p>{seconds} seconds ellapsed!</p>;
}
So the problem with this
was fixed by replacing it with another one; instead of having to know how classes and the
this
keyword work, developers have to know how closures work, and they can lead to even sneakier bugs in my opinion.
If this
is accidentally bound to a wrong value for whatever reason, then there will be an error in the developer
console. But as the above example show, the example with hooks will continue to work somehow, just not in the expected
way.
However, this can sometimes also be the desired behavior, as Kent C. Dodds describes in his blog
post, but interestingly I cannot remember a time where
the problem described in this blog post was a serious issue in my development work.
Return values are somehow weird
Some people might also think that the return values of e.g. the useState
hook is a bit weird (probably even the react
team themselves, since they devoted an own section in the documentation for
it).
export default function App() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
}
The useState
call in the above code makes use of array destructuring. useState
will return an array, containing:
- First, the current value of the state
- Second, a function to update the state and rerender the current component (i.e. re-execute the function)
When the function is re-executed, the useState
hook will return a different value for count
, but in a new function
execution. Therefore the count
variable can have a different value on each execution, although it is defined as
const
.
In order to retrieve the value and update function array destructuring is used. This decision has been made to allow
the variables however you wish because they are assigned by their position. The useState
hook itself does not name
that at all.
So, while this might make sense with this explanation, I would not say that this is very intuitive. The below code
might be more verbose, but I think it is easier to understand what is happening.
class App extends React.Component {
constructor() {
super();
this.state = {
count: 0,
};
}
render() {
const {count} = this.state;
return (
<button
onClick={() => this.setState({count: count + 1})}
>
Clicked {count} times
</button>
);
}
}
In addition, it makes sense that the class resp. object has an internal state, something that rather confuses in the
example with the functional component and hooks.
useEffect
has a strange second parameter
Even though useEffect
is probably my favorite hook, it has still a rather strange syntax, which makes it not that
easy to explain. Especially the second parameter is weird, which describes when the effect should be executed. It does
that by comparing each element of the second parameter (which is an array), and if they differ from the previous
render, then first the old cleanup function is executed and afterwards, the effect is run again.
Once understood that makes perfect sense, but it is not that easy to explain. Again, that is a sign for me, that there
might be an easier solution that makes fewer problems. It is also not that easy to recognize when the effect is
executed.
useEffect(() => {
console.log('Executed after every render');
});
useEffect(() => {
console.log('Executed only after component has mounted')
}, []);
The above two examples are not that easy to differentiate, so for a developer not being that familiar with react it
might be hard to remember this. The old lifecycle functions had their fair share of problems, for which reason I think
it is a step forward, but having methods on a class called componentDidMount
, componentWillUmount
, etc. was more
explicit and easier to understand for developers reading such a code for the first time.
Another thing that bothers me is that the useEffect
hook is always taken as an example of why hooks are better than
classes, but I think that is mixing two different things. The idea of effects is great, but they should be seen
decoupled from hooks. I think effects could have also been implemented using classes in one or the other way.
useMemo
and useCallback
might not even help with performance
The other thing is that some hooks force developers to generate even more code, which might cause performance to be
worse, even for hooks like useMemo
and useCallback
that are actually made for improving performance. However, that
only works in certain situations. Kent C. Dodds has written another blog post about
this. The main takeaway is that when using useCallback
or
useMemo
there is already another function call and array definition, and the performance win has to outweigh this
additional work.
When developing classes this is more or less already solved, at least apart from a few possible confusing scenarios
regarding the this
keyword. But as mentioned above, until now I have had no problems, if class properties were used.
I still want to do a benchmark to compare how much of a difference there is between a class component with a class
property function and a component using inline functions with hooks. Unfortunately, I didn't have time yet, but this
might be the topic of a future blog post.
Conclusion
Summed up I can agree with the fact, that there are some advantages with hooks, especially with useEffect
. But
especially the useEffect
hook could probably also be implemented using classes, at least in theory. Unfortunately,
there is no way to use something like effects with class components, which would be great because the concept has clear
advantages over the lifecycle methods.
But at the same time, it feels like hooks are just reimplementing some features of classes in functions, and from my gut
feeling, they are mainly combining the worst of both worlds. There are no pure functions anymore, so they have
integrated some of the drawbacks of classes.
When classes were introduced in JavaScript, there were a lot of people in the community that were against them, and I
guess they have not changed their minds until today. It somehow feels like a compromise, and neither party is really
happy. Functional JavaScript developers will never use classes, and people with an OOP background will still wonder
that they work a little bit differently from the classes they know from other programming languages. It would probably
be best to have two different programming languages here so that both parties are closer to their favorite
programming language. But unfortunately, JavaScript is the only language for client-side development… Hopefully,
WebAssembly will also allow communicating with the DOM one day so that there is room for many different programming
languages built on top of it.
These thoughts are coming off the top of my head, if you agree/disagree or have other issues with hooks please let me
know in the comments!
Top comments (0)