The introduction of hooks in React 16.8 has totally changed the way we implement logic in our React components. React hooks provided us with an entirely new way of encapsulating logic, sharing it between components, and keeping it separate from the view layer. The key to all of this is the fact that we can create our own custom hooks.
Before the introduction of React hooks, implementing logic in React components meant you had to use class components. For two years, this was exactly my situation. I worked at a company where the frontend team was still working with class components, even after React hooks were released. While there was nothing inherently wrong with this situation, the experience was suboptimal.
Implementing logic in React class components was messy, verbose, distributed, and extremely unorganised. Most of the time, it also didn’t allow for easy sharing between components. This was similar to what the React community experienced, which eventually lead to the creation of hooks. Using React hooks, implementing logic became structured, centralised, organised, and easily reusable across components.
One of the key reasons for this is the fact that React developers can encapsulate (small) pieces of logic into their own custom hooks. Nowadays, it’s a common practice in React development. But exactly because it’s so common, have you ever considered just how readable your way of creating custom hooks is?
This article will go over three different approaches to implement custom hooks: not abstracting anything, exposing a limited set of behaviour through an API, and putting everything in a custom hook. We will discuss the advantages and drawbacks in terms of readability, and the use cases for every approach.
This information will provide you with a solid foundation on how to implement custom hooks in your React components in a readable manner. After this article, you will be able to apply these approaches, identify when your code declines in readability, and keep more complex constructions readable by building on top of this knowledge.
Don’t Abstract Anything
One of the most straightforward approaches to implementing custom hooks is actually not creating them in the first place. We don’t abstract anything away and, instead, leave all the logic, utility, and hooks-related code are inline in the component. Generally, it could look as follows:
const Component = () => {
const [ids] = useArrayIds();
const [storedIds, setStoredIds] = useState(ids);
const removeId = useCallback((idToRemove) => {
setStoredIds((oldIds) => oldIds.filter((oldId) => oldId !== idToRemove));
}, []);
useEffect(() => {
setStoredIds(ids);
}, [ids]);
return (
<div className="component-container">
{storedIds.map((id) => (
<button key={id} onClick={() => removeId(id)}>
{/** Etc */}
</button>
))}
</div>
);
};
The biggest advantage to implementing custom hooks by not abstracting anything is that all the relevant code is contained in a single location. When reviewing, going through the code, or refactoring it, the reader never needs to reach out to additional resources to understand the code. This in turn prevents a lot of file and line switching, which also avoids a lot of unnecessary distractions or losses of focus. This is extremely beneficial for the readability of your React code, especially when readers are reviewing your code in the browser where IDE support is still very limited.
Another advantage to this implementation is that all the code for both the logic and the UI are kept together in a single place. This is especially noticeable in small components. This means that the vertical distance between the logic and the UI code is kept small. This is very beneficial for the readability of your React code as readers can more easily connect the UI with their appropriate logic code, variables, and utility functions.
As mentioned, the benefits of not abstracting hook code into custom hooks are highlighted in smaller components. On the other side, the drawbacks to this approach are mainly felt in larger components that have to deal with multiple logic flows, data sources, or content flows.
As a component grows and gains more responsibilities, so will the number of logic and content flows that reside in the component. Keeping them inline instead of abstracting them into custom hooks means that all their code will live together. That’ll make all the different flows intertwined with one another. This in turn will cause the complexity of the code to become exponentially more complex as the number of logic and content flows in a component increases.
On the other side, there’s also the vertical distance between the code for logic and UI. When the component only has to deal with a single logic flow or trivial logic, the vertical distance is kept small, and the code for logic and UI are located close to each other. As mentioned before, this is beneficial for readability.
But in reality, this isn’t something that will always happen. Components will grow, so will the number of logic flows as we just mentioned and so will the complexity of individual flows. In all cases, the result is that more code will be introduced to components to handle the additional logic. In turn, this will increase the vertical distance between code for the logic and UI, and distribute code for different flows vertically all over the component.
This makes it impossible to get a quick overview of a single logic flow or to easily connect the code for the logic with the UI. Instead, if in any scenario the readers want to get a complete picture of the code, they are required to skim through all of the code from top to bottom and connect the different pieces accordingly. This can take quite some time and effort, which ultimately obstructs the readability of the code.
- ✅ Everything is contained in a single location.
- ✅ Great for keeping the logic and UI together in small components.
- ⛔ The code becomes exponentially more complex as the number of logic flows in the component increase.
- ⛔ The vertical distance between code handling the logic and the UI can become quite big, which ultimately obstructs the readability.
Expose Limited Behaviour As An API
A different approach to implementing the logic flow in a custom hook is by only exposing a limited amount of behaviour of the flow as an API. All the functions and active functionalities are abstracted into the custom hook.
Users of the flow will call the custom hook and only receive the necessary behaviour functions to implement the actual logic. This means that the custom hook is responsible for describing how the logic works, while the component is responsible for integrating the flow into its own lifecycle. Generally, this would look as follows:
const useIdsHandler = (ids) => {
const [storedIds, setStoredIds] = useState(ids);
const removeId = useCallback((idToRemove) => {
setStoredIds((oldIds) => oldIds.filter((oldId) => oldId !== idToRemove));
}, []);
const resetIds = useCallback((newIds) => {
setStoredIds(newIds);
}, []);
return { storedIds, removeId, resetIds };
};
const Component = () => {
const [ids] = useArrayIds();
const { storedIds, removeId, resetIds } = useIdsHandler(ids);
useEffect(() => {
resetIds(ids);
}, [ids, resetIds]);
return (
<div className="component-container">
{storedIds.map((id) => (
<button key={id} onClick={() => removeId(id)}>
{/** Etc */}
</button>
))}
</div>
);
};
This approach is more like an intermediate solution, in between not abstracting anything (which we’ve just discussed) and putting everything inside a custom hook (which we’ll discuss afterwards). Because of this, the advantages to using this approach to implement custom hooks stand out less on their own. Instead, they’re more pronounced when considered together.
The first advantage of this approach is that it keeps the size of the component contained. Most of the code is abstracted in a custom hook, which means it’s moved away from the component. The only code remaining is the necessary behaviour. This significantly reduces the size of the component and makes it easier for the reader to keep an overview of the component.
Secondly, this approach attaches an explicit name to the behaviour and the logic. This is an often overlooked aspect when writing code. A common practice when writing more readable code is to extract values, functions, or conditionals into their own variables. This gives the respective code an explicit label describing what the code does. This approach does exactly that but for the behaviour exposed by the custom hook’s API. This helps the reader to understand the logic flow and thus has a positive impact on the readability of the code.
Lastly, this approach only abstracts the behaviour away into an API but leaves the usage of it to its users. This means that the components that use the custom hook are still the ones responsible for implementing the behaviour properly. When this is applied to multiple logic flows in a component, they are implemented closely to each other. This makes it easier for the reader to put different logic flows into the perspective of other flows and create a mental overview of all the interactions.
While being able to put multiple logic flows together inside the component can be an advantage, it can also be a drawback. Because they are all implemented in the component itself, the logic flows can become quite intertwined and difficult to separate. Factors that influence this are the number of logic flows, how often the different flows interact with each other, and how many of the different flows have to be executed in similar phases of the component’s lifecycle.
Depending on the combination of these factors, it can drastically change how easily a reader can go through the component’s code and understand everything. This makes it harder to gain an overview of all the logic flows, their interactions, connections, and interdependencies. Ultimately, this negatively affects the readability of the code.
Another drawback is that all the underlying details are hidden in the component. While having a custom hook in place is great for attaching a name and describing what the behaviour does, it doesn’t explain to the reader how it’s done. On top of that, the implementation of the logic is spread across multiple locations. The internals of the logic are located in the custom hook, while the usage is implemented in the component itself.
Situationally, this can have a significant negative impact on the readability of the code. In normal circumstances where the reader is only interested in the results and behaviour of the logic flow, this is not an issue. But when they want to dive into the specifics, details, integration into the component, and implementations, this distribution of code will cause more file, line, and code-switching. In those scenarios, this approach has a bigger negative impact on the readability of the React code compared to e.g. having all the code in one place.
- ✅ Keeps the size of the component contained.
- ✅ Attaches an explicit name to the behaviour of the logic and the logic itself.
- ✅ Easier to put different logic flows into the perspective of other flows.
- ⛔ More difficult to separate logic flows.
- ⛔ Usage of the custom hook describes what the behaviour is, but not how the underlying logic works.
- ⛔ Code for the logic flow is distributed across multiple locations.
Put Everything In A Custom Hook
Another approach to handling custom hooks is to literally put everything in a custom hook. This does exactly what it sounds like, namely that you take all of the code related to the logic flow and put it inside a custom hook. Then, you only return and expose the necessary logic, callbacks, or values from that custom hook for the component to use. Generally, that would look as follows:
const useIdsHandler = () => {
const [ids] = useArrayIds();
const [storedIds, setStoredIds] = useState(ids);
useEffect(() => {
setStoredIds(ids);
}, [ids]);
const removeId = useCallback((idToRemove) => {
setStoredIds((oldIds) => oldIds.filter((oldId) => oldId !== idToRemove));
}, []);
return { storedIds, removeId };
};
const Component = () => {
const { storedIds, removeId } = useIdsHandler();
return (
<div className="component-container">
{storedIds.map((id) => (
<button key={id} onClick={() => removeId(id)}>
{/** Etc */}
</button>
))}
</div>
);
};
The main advantage to implementing hooks logic by extracting everything into a custom hook is the amount of code. Because as much code as possible is abstracted away, it will result in as little remaining code as possible in the components themselves. This in turn will keep the component clean and will be beneficial to the readability of the code.
Another advantage to this approach is that every piece of logic is contained in one single place. Especially when dealing with multiple intertwined logic flows, this means that every flow is separated from one another. Sometimes when you’re going through the component’s code, you’re left wondering about the specific details of a certain single logic flow.
When implemented using this approach, you only have to navigate towards a single file or location to view everything related to that single logic flow. When trying to understand it, you’re not hindered or distracted by code from any other flow. This generally makes it easier to understand individual logic flows and therefore the component itself. In turn, this is tremendously beneficial for the readability of the React code.
The last advantage is related to the separation between code for the logic and the UI. When using custom hooks in such a way, the custom hook is responsible for everything related to the logic. As mentioned, the only things that it gives back are the necessary pieces for the UI to function properly. The API is minimal and provides a clear separation in responsibilities.
This clear separation between code for the logic and UI is very beneficial for the readability of the React code when readers are focused on understanding either part individually. It means that they can focus on either only the logic or only the UI, and don’t have to bother with the other. This reduces the amount of code that they have to keep track of, the cognitive load when reading through the code, and thus is positively beneficial for the readability.
One drawback to implementing logic flows by putting everything into a custom hook is that all the details are hidden from the component. When going through the component’s code, the reader will gain zero knowledge about the logic flow. This includes any behaviour, details, or side effects.
In a lot of cases, this can be perfectly acceptable. But for every detail that the reader wants to know about the logic flow, they will have to switch to the custom hook code. Making these switches between files or pieces of code actively obstruct the natural reading flow and can also introduce unnecessary distractions and losses of focus.
The biggest drawback of this approach is related to the previous drawback combined with its scalability. As mentioned, components are likely to contain more and more logic flows as the component itself scales. In certain scenarios, the logic flows will intertwine with one another in terms of their behaviour.
Understanding the connections, interactions, timings, and dependencies between logic flows can be crucial for understanding the component. But when all the logic flows are abstracted away in individual custom hooks, it becomes extremely difficult to create and keep track of a mental modal of all the different flows.
As a reader, on the surface, you’re only provided with the minimal amount of code that allows the component to make use of the logic. Understanding all the flows requires you to dive into the specifics of every custom hook, put them into perspective, and tying them all together. As the number of logic flows in a component grow, this can become an enormously difficult and time-consuming task, especially in the browser where IDE support is very limited.
- ✅ Abstracts as much code as possible, resulting in as little code in the component as possible.
- ✅ The code for every piece of logic is contained in one single place, separated from one another.
- ✅ Clear separation between code for the logic and the UI.
- ⛔ Hides all the details, including side effects, in the custom hook.
- ⛔ Difficult to imagine how different logic flows interact with each other.
Final Thoughts
In this article, we discussed three different approaches to implementing custom hooks in your React components. Either not abstracting anything and leaving all the code in the component itself, or abstracting most of the code towards a custom hook and exposing a limited amount of behaviour as an API, or abstracting everything into a custom hook.
If you liked this article, consider checking out the other entries in the Uncommon React newsletter or my Twitter for future updates. If you like my content in general, you could consider sponsoring me with a coffee or supporting me here to keep me going.