Daniel Rotter Web Developer

Some reasons for disliking react hooks

tags javascript, react
time 16 Jan 2022

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:

  1. 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.
  2. Then I got used to them and started to like them because they have certain advantages.
  3. 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 on Twitter!