ReactHooks Considered Harmful

Pau Ramon Revilla

Pau Ramon Revilla

10 minutes read

Oh, hooks!... I still remember seeing the presentation in 2018 when Sophie Alpert and Dan Abramov introduced them at React Conf. I was blown away, well, everybody was. It was such an innovative API that it took the frontend world by storm. Functional components with an easy way to separate stateful logic from rendering logic 🤯 . Did we achieve functional programming nirvana?

After several years of using them, I would love to share the dangers I encountered using hooks. I do not exaggerate when I claim that I find a dozen of hooks-related problems every single week while reviewing code. Most of those issues never manifest to the end-user, but incorrect code that is not a bug today will, eventually.

A broken watch gives the time correctly twice a day – Unknown

Closures

A common misunderstanding is that the object-oriented paradigm is stateful, and the functional one is stateless. The argument usually follows that state is evil, hence object-orientation must be avoided. There is some truth in that, but like most truisms, nuanced.

What does state mean? In computers, it means something along the lines of “keeping things around while I compute other things”, mostly in memory. Every time you store something in a variable, you keep state around for a given lifetime. It is then safe to say that the only difference between programming paradigms is how long you keep stuff around and the space-time tradeoffs that these decisions entail.

Shown below are two pieces of code that are equivalent in functionality:

1 2 3 4 5 6 7 8 9 class Hello { i = 0 inc () { return this.i++ } toString () {return String(this.i) } } const h = new Hello() console.log(h.inc()) // 1 console.log(h.inc()) // 2 console.log(h.toString()) // "2"
1 2 3 4 5 6 7 8 9 10 11 function Hello () { let i = 0 return { inc: () => i++, toString: () => String(i) } } const h = Hello() console.log(h.inc()) // 1 console.log(h.inc()) // 2 console.log(h.toString()) // "2"

The mechanisms to retain memory have a lot in common. Classes use this, which refers to the object’s instance, while functions implement closures - the ability to remember all the variables within their scope.

Closures are significant since they allow functions to be stateful; you don’t need objects or classes for that to happen.

One important caveat of closures is that they can easily provoke memory leaks - a function can outlive its scope, thereby the garbage collector can’t collect the trash. In the example above, as long as we keep inc around, we won’t cleanup i.

The other important thing about closures is that they turn explicit dependencies into implicit ones. When you send arguments to a function, that dependency is explicit, however, there is no way for the program to know the dependencies in a closure. The corollary of this is that closures are not deterministic, ie. the values they keep in memory can change between calls, yielding different results.

Closures - The undoing of hooks?

How does all that closure mambo-jambo translate into React? Well, I’m pretty sure the React team looked into all the possible APIs and made the best decision available, but, basing hooks on closures has begotten remarkable consequences:

1 2 3 4 5 6 7 function User ({ user }) { useEffect(() => { console.log(user.name) }, []) // exhaustive-deps eslint will bark return <span>{user.name}</span> }

The idea behind hooks is that they produce side effects whenever their dependencies change; for example, useEffect should run only when the inputs required for the side-effect are different, like an excel sheet. The same applies to useMemo and useCallback.

Hooks benefit from closures because they can “see” and retain information from their scope, for instance, in the example above, user. However, with closure dependencies being implicit, it is impossible to know when to run the side-effect.

Closures are the reason why the hooks API needs an array of dependencies. This decision forces the programmer to be responsible for making explicit those implicit dependencies, thereby functioning as a “human compiler” of some sort. Declaring dependencies is manual boilerplate work and error-prone, like C memory management.

If you’ve ever done manual event subscription management, you would be familiar with the two main problems: over-subscription and under-subscription, ie. reacting too much and reacting too little. The former tends to result in performance problems and the latter into bugs.

React’s solution to this problem is a linter, but it becomes a moving target when React hooks are composed into custom ones. Not to mention the fact that the linter does not have enough information and will often lead to over-subscription, as we will see when talking about data structures.

There is an alternative API for hooks that avoids this problem altogether: moving hooks outside the component. This forces you to pass arguments, which can be reasonably used as dependencies:

1 2 3 4 5 6 7 8 const createEffect = (fn) => (...args) => useEffect(() => fn(...args), args) const useDebugUser = createEffect((user) => { console.log(user.name) }) function User ({ user }) { useDebugUser(user) return <span>{user.name}</span> }

Moving the hook outside the closure will free you from manually tracking dependencies and running into under-subscription problems. But you will still be vulnerable to over-subscription related to how React – and javascript – interpret two dependencies as equal.

Identity and memory

Identity is a tricky concept. It looks easy because humans have an intuition about it; we recognize objects and people even when they change over time. Nevertheless, philosophically, identity is a complex topic.

No man ever steps in the same river twice, for it’s not the same river, and he’s not the same man. – Heraclitus, organizer of the first Greek Clojure community back in 500BC

Identity is easy for things that don’t change, eg. 3 will always be 3. We can confidently claim that 3 == 3, but when things change, and we replace every plank out of the Theseus ship, what are we left with? Is it the same ship? Does ship == replacePlanks(ship)?

All programming languages find themselves at this philosophical crossroad. For example, some languages will ban mutation altogether, making the problem impossible. This means that attempting to renovate an “immutable” ship will always lead to creating a new one since you can’t replace the planks.

Immutability can have negative performance implications due to building things repeatedly, but the identity properties can offset the cost since deterministic functions are easier to cache.

Javascript and many other languages settle the debate by having different ways of asking for equality. For instance, == , === and Object.is are completely different questions and will yield different answers. Object.is is the latest addition to the family and evaluates that the values are equal.

  • both undefined
  • both null
  • both true or both false
  • both +0
  • both -0
  • both NaN
  • or both non-zero and both not NaN and both have the same value
  • For strings, it checks that the size is the same and the characters are in the same order.
  • The rest are non-primitives, and since those can mutate, we check that the memory reference is the same. This defies our intuition. For instance, Object.is([], []) is false, because both objects have a different pointer in memory, but let a = b = []; Object.is(a, b) is true because both variables point to the same.

This last part is essential because it makes it impossible for the developer to predict if two objects are the same. Given two objects, one can’t tell whether Object.is is going to return true or false unless we understand how those objects reside in memory.

Hooks and Identity

Hooks use Object.is to check dependencies. Given two sets of dependencies, the hook will only run if those are not “the same.” In this case, “sameness” is determined by the Object.is semantics described above.

Let’s try to see if you understand the challenge with the following snippet:

1 2 3 4 5 6 7 const User({ user }) { useEffect(() => { console.log(`hello ${user.name}`) }, [user]) // eslint barked, so we added this dependency return <span>{user.name}</span> }

Given what we know about this component, how many times will the useEffect run? We can’t say. It will run exactly once for each different user we receive. Remember what we said about identity? We can’t know about the identity of an object without knowing how memory was allocated. And the problem is that this memory allocation happens elsewhere, meaning that this code may work, but it’s incorrect, and a change in a parent component will completely break it.

1 2 3 4 5 6 7 8 9 10 function App1 () { const user = { name: 'paco' } return <User user={user} /> } const user = { name: 'paco' } function App2 () { return <User user={user} /> }

In the example above, we can see the subtleties of hooks.

In App1 we allocate a new object every time. To a human, that object is always the same, but for Object.is, it’s not. This means that each time we render this component will run the side effect logging “hello paco”.

In App2 though, we always refer to the same object pointer, which means that the side effect will correctly log only once, no matter how many times we render it.

This example does not resemble real-life code, but I wanted to show the problem with the simplest case. In reality, code is much more complex, and it’s hard for developers to understand when an object is being allocated and for how long.

Here is an example that is closer to production-like code:

1 2 3 4 5 6 7 8 9 10 11 12 function App ({ options, teamId }) { const [user, setUser] = useState(null) const params = { ...options, teamId } useEffect(() => { fetch(`/teams/${params.teamId}/user`) .then(response => response.json) .then(user => { setUser(user) }) }, [params]) return <User user={user} params={params} /> }

This code will smash your server, making the same request repeatedly. Object restructuring allocates a new object for each render, making the useEffect dependencies useless. This is a clear case of over-subscription and a bug that your users may not suffer, but your server will.

Conclusion

Hooks, like every technology, have enjoyed the blessing of the new technology hype cycle. Many developers have ditched their state management solutions and embraced hooks for implementing stateful logic. Also, the API looks easy, but it’s deceptively complex underneath, increasing the risk of incorrectness.

We are at the point where people are starting to realize the rough edges of its API and the dangers it poses for large-scale applications.

Most bugs can be solved by moving hooks away from the components and using primitives as the only dependencies. If you use Typescript, you can always create your own hooks and type them strictly. This will help developers in your team greatly understand the limitations.

1 2 3 4 5 6 7 8 9 10 11 type Primitive = boolean | number | string | bigint | null | undefined type Callback = (...args: Primitive[]) => void type UnsafeCallback = (...args: any[]) => void const createEffect = (fn: Callback): Callback => (...args) => { useEffect(() => fn(...args), args) } const createUnsafeEffect = (fn: UnsafeCallback): UnsafeCallback => (...args) => { useEffect(() => fn(...args), args) }

If you are not using Typescript, it may just be time to look for alternatives. With the additions of libraries such as zustand, jotai, and the old-timers redux and mobx, there exist plenty of options to choose from. Those libraries will make your life easier, ensuring that code is not only working but correct.

Pau Ramon Revilla
CTO

Pau Ramon is Factorial’s CTO and co-founder. He likes people and computers, in that order.

Liked this read?Join our team of lovely humans

We're looking for outstanding people like you to become part of our team. Do you like shipping code that adds value on a daily basis while working closely with an amazing bunch of people?

Made with ❤️ by Factorial's Team