Getting Out of Hook Hell: You Are Using React Hooks The Wrong Way

React Hooks are a very powerful tool to add interactivity and features to user interfaces. As your application grows, you will increasingly rely on hooks, composing simple ones into complex hooks, and, before you know it, end up with an unreadable, unmaintainable mess that rerenders 5 times a second for no apparent reason. In this article, I will show you some React Hooks antipatterns and how to avoid them if you want a clean, readable, and robust application.

In this article I will focus on the most used built-in hooks: useEffect, useMemo, useState, useReducer, and useContext.

useEffect

One most important thing about useEffect that you need to know in order to use it correctly is that useEffect is not a lifecycle hook. It should not be used as a hook to “do something on first render”, to make API calls, or to load data. While it can work that way, it absolutely should not, for reasons I will explain below.

The mental model is synchronization. Not lifecycle.

Dan Abramov back in 2019
  • Firstly, there is no guarantee that it will run only once, even if you specify an empty dependency array like useEffect(fn, []). As the application grows, you can encounter weird side effects, especially paired with SSR, and end up with multiple invocations. In the best case, it will just double the load on your API server, and in the worst case introduce some race conditions or infinite loops. Moreover, React as of version 18 will run effects twice (on purpose) in strict development mode only. This is done to uncover potential errors and misuse of useEffect earlier.
  • Secondly, it is very hard to read and maintain code riddled with useEffects. Since the behaviour of useEffect is so arbitrary, once a component has more than 1 effect it becomes increasingly complex. On the other hand, if you use other, more suitable, replacements for useEffect (I will list them below), the code becomes much cleaner.
  • Thirdly, it is much more difficult to test code with many effects. Since you cannot call the effect handler directly, you have to render the component (or hook) and perform some dark magic to get the effect to work.

The way you should think about useEffect is a synchronization mechanism. For example, setting the browser tab title based on a component prop will be a perfect example. This hook is designed to sync your React component with some (client-based) system outside of React.

So what should you use instead of useEffect? I have some suggestions for you based on the use case:

  • For fetching data on component load: use one of the render-as-you-fetch solutions. These were introduced with React 18 and rely on the Suspense feature. It means the render function becomes async, and you provide a fallback component (e.g. spinner) to show while the main component loads. You do not even have to use suspense directly, as modern React frameworks already have adopted it: useLoaderData from React Router, getServerSideProps or SWR (not suspense but good practice nonetheless) with Next, useFocusEffect from React Navigation for React Native (also not suspense), React Query, new use hook (beta).
  • For transforming data: if you have dome data processing that needs to run on every render, consider just putting it in the render function without a hook. If it is conditional on some props, or expensive in nature, use useMemo hook.
  • For subscribing to an external store: if you need to subscribe to something outside of React (e.g. browser’s online state, local storage, or sockets), consider using the new useSyncExternalStore hook.
  • For user events and communicating with parents: use event handlers. There is no need to use an extra effect to call a callback when you can call it directly.

In the end, there are only a few viable use cases for useEffect, and even fewer with new hooks and features introduced in React 18. Every time you find yourself writing useEffect, try to find alternate implementations, as they will likely be better.

useMemo

useMemo is a very nice hook that lets you memorize (cache) the value of some computation, and only run it when relevant inputs change. While using useMemo seems like a no-brainer when you transform data, I would urge you to only use it if the computation is truly expensive and you notice some performance impact. Otherwise, it would be premature optimization.

Premature optimization is the root of all evil.

Donald Knuth, “father” of algorithm analysis

There is no point in optimizing trivial code, and it comes with the overhead of memory consumption (cache is stored in memory) and complexity (makes code harder to read). Also, it makes it easier to shoot yourself in the leg (if you forget to add a dependency to the deps array). So, unless your data really is expensive to compute, stay away from useMemo.

useState

useState hook is probably the most used one in the React ecosystem, together with useEffect. It is used to preserve some component state across renders, to make them useful.

It is hard to find anything wrong with useState: after all, it is just a glorified variable definition for React components. But there are definitely problems with using more than 2-3 states in a component. Just like regular variables, too much of them in a single function can make your code an unreadable mess.

If you find yourself using more than 2 states in your component, consider going one of these ways instead:

  • Split a component/hook into 2: using too much state in a component can signal that it takes on too much responsibility. Per the single responsibility principle, it may be better to split the component into multiple.
  • Use useReducer: if your component must have a complex state, consider using one useReducer hook instead of multiple state hooks. By moving the reducing logic outside the element and by having fewer variables/setters, you can get a cleaner component. For more info about this hook, you can consult React docs.

useReducer

In the previous section, I advocated for using useReducer instead of multiple useState, and now I will go over useReducer in the same manner.

This hook is perfect for storing complex local states for very specific use cases. Generally, components should be small and simple enough to make do without reducers. However, if you find yourself using reducers, there are some additional concerns to consider.

One particular stress point is passing values from the reducer down the component tree. If you find yourself prop drilling values from the reducer, it should be a red flag and call for a reevaluation. If you are in such a situation, consider one of these options:

  • Split the reducing logic into multiple reducers/hooks. If your reducer values are drilled down the component tree, chances are, that state could be simplified and split up. You could introduce some additional hooks that will hold those pieces.
  • Put the reducer in a context. If your reducer state must be shared across components, it should be globally accessible using the useContext hook. You can then introduce a single hook that all components could use to access that data. For more info about useContext, consult the docs.

useContext

The useContext hook is invaluable when you need to share some state across components, but, as always, it has some antipatterns.

If you see that your context holds some global state that is an integral part of your application and provides it at the top level, you should consider your options carefully. Multiple global contexts can be hard to read and will require the use of multiple hooks at the component level. Moreover, it will be harder to debug the renders (React dev tools are not very good in that regard).

If you are struggling with one or more “god” contexts, consider one of these:

  • Split the god context into smaller, *local* contexts. The emphasis is on local. With the rise of libraries like useQuery, most applications do not need global contexts, except for features like theming or localization. You should be able to refactor one big global context into multiple small, local, contexts.
  • Use Redux. If you must use a global state, consider using Redux. It is much more suited for larger states and has better dev tools and library integrations. Debugging and logging redux is a breeze, and RTK (Redux Toolkit) helps cut down on boilerplate, which was always the selling point of React Context. Additionally, it is going to be easier to manage SSR and reconciliation strategies with Redux than with multiple contexts.

Business logic in hooks

My last principle is not to run business logic of any kind in hooks. React Components and Hooks are purely for UI logic. Using them to process any kind of business logic will not end well for you. React is a UI library only, and nothing else. If you mix UI and business logic, you will violate the single responsibility principle, which is always a bad idea. Your hooks should communicate with entities responsible for business logic, through some real layer like network or some imaginary boundary.

These are some places that are appropriate for business logic:

  • Server. Ideally, your backend should be responsible for all business logic. This makes your app cleaner, faster, and more resilient to attacks (since the attacker does not have access to the server). This can be in the form of classic backend servers (Node, Python, Java, etc.), SSR solutions (NextJS API handlers), or serverless functions (AWS Lambda).
  • Plain functions. If using a server is not an option (your app consists of clients only), you can put business logic in JS functions, but be careful to keep them separate from your React code. There are no use cases for using hooks and contexts in business logic functions.

Closing notes

In this article, I explored some common React Hooks antipatterns, how to recognize them, and how to avoid them. Please let me know in the comments if you know any additional antipatterns, or if you disagree with mine!

Get new content delivered to your mailbox:

leave a comment