Custom Hooks possible issues: how to avoid them

Custom Hooks are great, but how do we handle the issues they sometimes cause?

As we all know, using any kind of tool recklessly can lead to problems that might negatively impact your application. But what about custom Hooks?

Hooks are reusable, personalized functions that are handy and practical, right? They’re essentially useful functions for React components, and using them has become a pretty common practice. If you’ve had even a bit of experience with React programming, you’ve probably created a custom hook or two.

It’s clear that custom hooks are very useful in React. However, using them carelessly can lead to issues. So, to help you avoid turning this potential ally into a foe, let’s dive into some common pitfalls. I’ll focus on the challenges around re-rendering and unintended side effects that can sneak up when you’re working with custom hooks.

Table of contents

  1. Hooks: state sharing
  2. Hooks: re-renders
    1. State change
    2. Nested Hooks
  3. How to proceed now?

Of course, re-renders can’t always be avoided, and they aren’t always harmful. In this blog post, I’ll share tips for situations where preventing re-renders is necessary—such as when dealing with complex components that don’t need frequent updates or when re-rendering multiple components simultaneously might disrupt your app’s flow, especially when multiple custom hooks are involved.

First, let’s quickly review some custom hook concepts.

Hooks: state sharing

Assuming you’re already familiar with what a Hook and a custom Hook are, let’s focus on the state and how its changes affect the custom Hook and the component that uses it, shall we?

According to the great documentation provided by React, custom Hooks do not share state. But what does this mean?

We will see an example of this in the application we will use for the hands-on part of this blog post.

It is a simple listing of cats, with initial values called initialCatsList, which includes the Siamese cat and the Maine Coon. My custom Hook useCatList uses this array as its initial value. It also has a function that allows us to add new cats:

const initialCatList = [
  { breed: "Siamese", image: siameseImage, country: "Thailand" },
  { breed: "Maine Coon", image: maineCoonImage, country: "USA" }
]

const useCatList = () => {
  const [cats, setCats] = useState(initialCatList);

  const addCat = (cat) => {
    const newCatWithId = {
      ...cat,
      id: `${cat.breed.toLowerCase()}-${Date.now()}`,
    };
    setCats((prevCats) => [...prevCats, newCatWithId]);
  };

  return { cats, addCat };
};

With that in mind, we’ll create two list components: CatList and TotallyDifferentCatList. They use the same custom hook: useCatList. The first list will add two cats when it renders for the first time, with the help of useEffect. Only as an example in this fictional case. It’s not a good practice to use useEffect for everything. And if you want to learn more about this, check out this blog post CodeTips#11: useEffect the right way.

However, to make it work correctly, we’ll need to modify our main.jsx file to disable React Strict Mode, like this:

import { createRoot } from  'react-dom/client'
import  App  from  './App.jsx'

createRoot(document.getElementById('root')).render(
// <StrictMode>
<App  />
// </StrictMode>
)

Why? Well, I’ll explain in more detail in the next paragraphs, but React runs functions twice in Strict Mode to help find bugs and errors. This behavior is related to the concept of keeping functions pure (in this case, without it, four cats would be added instead of two). Again, don’t worry, I’ll come back to this shortly.

Note: There is also a way of solving this with useRef, avoiding the need to comment out the Strict Mode line, but this is an uncommon approach since useRef is not typically used in this way. I provide both solutions in the repository link of my project here — specifically the version without useRef and the version with it. I’ll explain further in the following paragraphs why we’d need useRef in this example, so keep that in mind.

Anyway, let’s look at the example below:

const CatList = () => {
  const { cats, addCat } = useCatList();

  useEffect(() => {
    addCat({ breed: "Bengal", image: bengalImage, country: "USA" });
    addCat({
      breed: "Russian Blue",
      image: russianBlueImage,
      country: "Russia",
    });
  }, []);
  return (
    <div className="cat-list">
      <h2>First list</h2>
      {cats.map((cat) => (
        <CatCard
          key={cat.id}
          breed={cat.breed}
          image={cat.image}
          country={cat.country}
        />
      ))}
    </div>
  );
};
const TotallyDifferentCatList = () => {
  const { cats } = useCatList();

  return (
    <div className="cat-list">
      <h2>Different list</h2>
      {cats.map((cat) => (
        <CatCard
          key={cat.id}
          breed={cat.breed}
          image={cat.image}
          country={cat.country}
        />
      ))}
    </div>
  );
};

export { CatList, TotallyDifferentCatList };

Will the second list render with the third and fourth members recently added by the first component? As you can see below, this was not the case.

Two cat lists. The second one does not have the same amount of cats than the first one.

As we just saw, each case is completely independent of the other. Even though they are using the same custom Hook, these are called in different components. In other words, custom Hooks do not share their state across different calls without the help of another function or API. We know that many types of applications need to share information between their components to function properly.

One solution would be to pass the state through props. As the documentation mentions:

When you need to share the state itself between multiple components,
lift it up and pass it down instead.

Another, more robust solution that is often used would be using the useContext. In fact, this is precisely why custom Hooks are so frequently used alongside this API in apps: sharing state is vital in an application. And as we’ve seen, custom Hooks alone do not share context.

Note: I’ll be using useContext here, but other popular options like Redux or Zustand are available as well. Depending on your app’s requirements, you can choose the best tool to manage state sharing within your custom hooks.

Now, let’s look at the previous example using the Context API. I also decided to save the list in local storage and used useMemo in the context return to ensure that the object is not created frequently (don’t worry, we will discuss useMemo in the following topics).

Let’s see useContext being used:

Javascript code of a custom Hook for a cat list using local storage , setCatList and useEffect.

Check the code here. But I’ll also provide the links for the final version and the repository in the last paragraphs.

In the project, my initial list still contains those same two cats from the beginning. And the first list still adds two new cats to the family. But with the above changes will the two lists end up with the same updated value?

Well, if we didn’t disable Strict Mode or use useRef in the component’s implementation (depending on which approach you choose from the options I mentioned), we would encounter a duplication of the added values. This happens due to React’s Strict Mode. As explained in the React documentation:

To help you find accidentally impure code, Strict Mode calls some of
your functions (only the ones that should be pure) twice in
development.

It’s important to emphasize that components and their custom Hooks should be pure, we shall modify their values only through state or other APIs.

The correct use of custom Hooks depends on following certain rules to ensure your application functions well. It is not recommended to add items directly within components, as shown. In our case, we are merely testing a hypothetical situation to understand a concept!

So, to test the same situation as in the previous example, but this time with context, we will continue to check if the items have already been added to avoid duplications, which is why we are using useRef and have used it in one of the options at the beginning of the post.

So, our component will look like this:

const CatList = () => {
  const { cats, addCat } = useCatList();
  const hasAddedCats = useRef(false); 
  // useRef to control if the cats are already added

  useEffect(() => {
    if (!hasAddedCats.current) {
      addCat({ breed: "Bengal", image: bengalImage, country: "USA" });
      addCat({ breed: "Russian Blue", image: russianBlueImage, country: "Russia" });
      hasAddedCats.current = true; 
    }
  }, [addCat]); 
  return (
    <div className="cat-list">
      <h2>First list</h2>
      {cats.map((cat) => (
        <CatCard
          key={cat.id}
          breed={cat.breed}
          image={cat.image}
          country={cat.country}
        />
      ))}
    </div>
  );
};

Will we get the expected result? As you can see below, the two new cats joined the feline family of our list in the context, both being passed as components:

Two equal cat lists.

Of course, if we refresh the page, these last two cats will be added again. But it’s interesting to note how the use of Hooks can facilitate component interaction while simultaneously adding complexity to them.

Moreover, the concept of rendering can be quite delicate when handling custom Hooks and nested Hooks. After all, custom Hooks also re-render with the component, and a function declared within a custom Hook will be recreated on each render, just like a variable, for example. It’s designed that way because custom Hooks always have the most recent props available to ensure your application functions correctly.

In modern applications, having the most up-to-date data is essential, and React apps must consistently provide users with the most current information.

However, there are some specific situations when multiple re-renders can negatively impact your application… When would that be?

Hooks: re-renders

Let’s understand when state changes and nested Hooks can lead to uncontrolled consequences in our applications.

When a change occurs, React does not modify the DOM unless the resulting component is indeed different from its previous version. When we use custom Hooks, we are often updating some value or part of our component, making it different from the previous version. This is part of React’s behavior, of course.

I won’t explain this process in depth, but in short, the rendering of components on the screen by React has three phases:

  1. Triggering: when the component needs to render initially or when the component itself or its parents update.
  2. Rendering: when React calls our components.
  3. Committing: when the actual change is made in the DOM, with the minimum necessary calculations, as mentioned earlier.

This helps us understand the importance of minimizing re-renders and updates in our application, even though state sharing is crucial. If you want to understand this step-by-step process better, read more in the documentation here.

And the documentation also mentions:

The default behavior of rendering all components nested within the
updated component is not optimal for performance if the updated
component is very high in the tree.

Meaning, we may have performance issues if a parent component affects several child components, potentially making up most of our application.

Of course, before components appear on the screen, they need to be rendered. It’s not always necessary to prevent re-renders, as they are essential for reflecting changes in our applications and are a core part of React’s design. Additionally, it’s unrealistic to avoid every unnecessary re-render. Instead, we can aim to minimize them in specific cases where components take a long time to render.

However, we should be cautious and avoid "over-optimizing". In the following topics, I’ll cover some strategies to prevent unnecessary re-renders, but they should only be applied when truly needed.

State change

As we know, custom Hooks often use state and the problem with many re-renders can happen precisely because of this.

After all, the host component will re-render if the state inside the custom hook changes.

Let’s check out a more complete version of our CatApp. Now it has a header with a light/dark toggle button and a button that opens a modal with a form to add a new cat to the list. To visualize when the components are rendered, they all have console.log statements indicating when they do.

First, let’s look at the component tree in our App.jsx; it looks like this:

const App = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <ThemeProvider>
      <AppWrapper> 
        <CatProvider>
          <Header />
          <Main>
            <button onClick={() => setIsModalOpen(true)}>Add Cat</button>
            <CatList />
            <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
          </Main>
        </CatProvider>
      </AppWrapper>
    </ThemeProvider>
  );
};

Notice now how, in the case below, several console.log messages appear when I click the button to open the modal. (Remember that the duplication occurs due to Strict Mode.)

A gif showing the cat application with dev tools open showing multiple re-renders when using the app.

Unfortunately, all components are being rendered again, even though we only interacted with the button that opens the modal! But we must remember the rule: whenever a component’s state is modified, there will be another render in that component.

In this small example, this may not make much difference. However, imagine that I was dealing with a list of heavy components that depended on large values and calculations. This would certainly impact the app’s performance, interactivity, and memory consumption.

This is because we are tying the state of the wrapper component to the App component. Since the wrapper component can end up being much larger than you might think, this can cause performance issues.

However, surprisingly, the same does not happen when clicking the theme toggle button in the Header component.

In the app, while toggling the theme, only the header console log message appears and re-appears.

Hum, interesting. Why didn’t this lead to multiple re-renders as well, since we also call the component in App.jsx? Let’s check the implementation of the Header:

Javascript code of a  React component called Header with a console.log every time it renders and a button for toggling the theme. The component uses useTheme custom Hook.

The Header component is the host component that uses the Hook. This means that only the Header and its children will re-render if there is a state change.

Meaning, we can also similarly solve our modal problem, right? Yes, something like that. Our solution would be: componentizing the state. In other words, we can limit its scope. We can create a component that already returns the modal and its state. This way, we’re limiting its influence. Let’s first create a custom Hook that basically does what useState already does and will take care of the Modal’s responsibilities, let’s call it useModal; it returns the following:

It's being returned: isOpen, openModal, closeModal, form, setForm, handleImageChange and handleSubmit.

With this, we have a modal wrapper that limits the scope of the re-render for the custom Hook used:

const ModalWrapper = () => {
  const {
    isOpen,
    closeModal,
    form,
    setForm,
    handleImageChange,
    handleSubmit,
    openModal,
  } = useModal();

  return (
    <>
      <button onClick={openModal}>Add Cat</button>
      <Modal
        isOpen={isOpen}
        onClose={closeModal}
        form={form}
        setForm={setForm}
        handleImageChange={handleImageChange}
        handleSubmit={handleSubmit}
      />
    </>
  );
};

Cool, right? Now our App.jsx will look like this:

const App = () => {
  return (
    <ThemeProvider>
      <AppWrapper>
        <CatProvider>
          <Header />
          <Main>
            <ModalWrapper />
            <CatList />
          </Main>
        </CatProvider>
      </AppWrapper>
    </ThemeProvider>
  );
};

Did it work? Did we limit the number of renderings? As you can see below, yes. Now, only the Modal is re-rendered when we click on "Add Cat":

Now only the modal console log message appear when opening the modal in the app.

We can still improve the situation with the Header component and make only the button re-render. Let’s imagine that our Header has dynamic data, animations, and even a search for heavy data. In this case, it would be interesting to have only the theme toggle button re-render, right? Following the same concept, I created a component called ThemeToggleButton and we call it here:

const Header = () => {
  console.log("Header rendered!");

  return (
    <header className="header">
      <h1>CatApp</h1>
      <ThemeToggleButton /> {/* Using the new component here */}
    </header>
  );
};

Finally, when toggling the theme, we only re-render the new button:

The toggle button now only makes the toggle console log message appear.

This way, you have a more organized code with a better separation of responsibilities. We have reduced unnecessary renderings, which even helps us test more easily, as more isolated tests can be conducted since the components are more focused.

While CatApp is a small application without performance issues, the tips on avoiding re-renders are more relevant for larger applications with complex components. It’s unrealistic to expect that we can always prevent re-renders, but we should focus on avoiding unnecessary ones that could negatively impact our apps’ performance.

That said, there are still other potential issues when using custom hooks.

Nested Hooks

It is important to remember that even custom Hooks within custom Hooks also propagate the re-render! Any state change within the custom Hook will trigger a re-render.

Additionally, using useMemo in the return of a custom Hook will not solve the rendering issue.

Let’s take another look at the CatApp application. My useModal also uses useCatList in its implementation, specifically the addCat function. After adding a cat, since the modal is closed and removed from user visualization, it does not render again, but my entire cat list is re-rendered.

The modal is opened and multiple console log messages appear.

This occurs due to the cat list being updated after we created another cat. This modifies the state of the CatList component, resulting in all those re-renders of the CatCards as well. That happens even after using useMemo:

Javascript code of useMemo being used in the value return of the cusotm Hook useCatList.

This happens because useMemo is not designed to prevent renderings, as stated in the React documentation:

useMemo is a React Hook that lets you cache the result of a
calculation between re-renders.

It prevents exhaustive calculations but not new renders. It caches the return value of a function, so your application doesn’t have to process it again after rendering.

So, how can we solve this situation? Well, we can use React.memo().

Check the code here.

Note: overusing memoization can also lead to issues. It consumes more memory in exchange for better performance—it’s a trade-off. After all, the result of a calculation needs to be stored somewhere, right? Its use is recommended only for high-cost calculations. In the next paragraphs, I’ll use memoization several times to demonstrate how it works, but keep in mind that it should be used with caution!

But what is the difference between React.memo() and useMemo?

React.memo() has a specific purpose, according to React:

Wrap a component in memo to get a memoized version of that
component. This memoized version of your component will usually not be re-rendered when its parent component is re-rendered as long as its props have not changed.

React.memo() is used to memoize components. It prevents the component from re-rendering if its props remain the same. It is often used when you want to avoid re-renders with items that never or rarely change. This fits our case perfectly! For now, our initial list is static, and our application only adds cats; it doesn’t edit a large number of them.

However, it’s important to remember that even when using React.memo(), your components may still need to render again. If your component uses Hooks, custom Hooks, and/or Context, it can still re-render.

In our case, React.memo() will be useful and will prevent the entire list from re-rendering after adding a new cat to the family. Below, you can see how we can wrap our CatCard component with React.memo:

import React from "react";

const CatCard = React.memo(({ breed, image, country }) => {
  console.log("CatCard rendered!");
  return (
    <div className="cat-card">
      <img src={image} alt={breed} />
      <h2>{breed}</h2>
      <p>{country}</p>
    </div>
  );
});

export default CatCard;

Note: we should use React.memo on the cards and not on the list. The list uses a custom Hook that manages the state of the cat list, which in this situation has just been updated by adding another item. Therefore, React.memo will not prevent the re-rendering of the entire list. On the other hand, CatCard does not use custom Hooks and only receives props.

A new item is being added to the application though filling a modal form with its breed, image and country.

Notice that only the list component and the new card were rendered again, but all the other items in the list did not need to re-render. We were able to avoid processing and added an apple to the cat list (this definitely makes sense). But what matters is that we discovered another way to prevent some of the problems that custom Hooks can create in our applications.

There is also a Hook that deals with memoization and can be useful in some cases: useCallback. Its definition is somewhat similar to useMemo:

useCallback is a React Hook that lets you cache a function
definition between re-renders.

Remember that functions are also recreated when there is a state change in the custom Hook. In this case, the cache prevents new references of the functions from being created after each render. This can be useful when your custom Hook returns many functions that, in turn, are used as props in components.

We can note that useModal returns some functions:

 return {
    isOpen,
    openModal,
    closeModal,
    form,
    setForm,
    handleImageChange,
    handleSubmit,
  };

Okay, but how can we test if our functions have been recreated or not? Let’s use useEffect with simple messages in console.log:

Javascript code with a useEffect with multiple console log for each function.
Check the code here.

Let’s check how many times they are created without useCallback:

A gif showing multiple function calls while using the app through dev tools console log.

They are indeed recreated every time there is a state change in useModal, which can be concerning. So, how can we optimize this? To use useCallback with these functions, we would need to do the following:

const useModal = () => {
  const { addCat } = useCatList();
  const [isOpen, setIsOpen] = useState(false);
  const [form, setForm] = useState({ breed: "", image: "", country: "" });

  const openModal = useCallback(() => {
    setIsOpen(true);
  }, []);

  const closeModal = useCallback(() => {
    setIsOpen(false);
    resetForm();
  }, []);

  const resetForm = useCallback(() => {
    setForm({ breed: "", image: "", country: "" });
  }, []);

  const handleImageChange = useCallback((e) => {
    const file = e.target.files[0];
    if (file) {
      const reader = new FileReader();
      reader.onloadend = () => {
        setForm((prev) => ({ ...prev, image: reader.result }));
      };
      reader.readAsDataURL(file);
    }
  }, []);

  const handleSubmit = useCallback(
    (e) => {
      e.preventDefault();
      addCat(form);
      closeModal();
    },
    [addCat, form]
  );

  return {
    isOpen,
    openModal,
    closeModal,
    form,
    setForm,
    handleImageChange,
    handleSubmit,
  };
};

Ready to find out if it worked? Take a look below, I create another "cat" and open and close the modal.

A gif showing the useCallback console logs. Only one function was logged.

Above, when we refresh the page or press F5, we will see all the messages about the initial creation of these functions. I cleared the console so we could better visualize only the messages that appear when opening and closing the modal. But notice that all the previously created functions did not need to be recreated. The only one that was affected by the change was handleSubmit, as I added the dependencies to it.

The function addCat is used within handleSubmit, and the form contains the data that needs to be added. That’s why it has these dependencies.

With this, our small application now avoids unnecessary function recreations. And, by using useCallback in our applications, we prevent potential child components that use a function as a prop from rendering again.

However, be cautious not to overuse useCallback, useMemo, or React.memo(), as they can consume memory with cached values. It’s impractical to memoize everything. Keep in mind that only intense calculations or components/functions that rarely change in your app truly benefit from memoization.

Even though custom Hooks can introduce side effects, there are effective ways to prevent rendering issues and uncontrolled consequences in complex applications or real apps. This way, your app can maintain strong performance while using custom Hooks wisely! 🙂

You can find our small CatApp with the optimizations here and the repository with the final code here.

How to proceed now?

We have seen the importance of understanding the problems that can arise from using multiple Hooks and chained custom Hooks. Custom Hooks are excellent solutions for many issues in our applications, but they also come with drawbacks.

I also recommend that after reading this blog post, you check out another post of ours that discusses custom Hooks at a more advanced level in a practical way: Scalable Frontend #4 – Custom Hooks to the Rescue.

In large, high-demand applications, we can impact the end-user experience if we do not address some rendering issues. Therefore, we can benefit from further studying use cases related to state componentization, separation of responsibilities, and renderings in React.

Of course, following best practices in creating custom Hooks can also greatly assist us. I plan to discuss these additional tips and best practices in another blog post in the future. However, I hope that the points presented here have already been useful and practical for understanding how to avoid these problems!

References

We want to work with you. Check out our "What We Do" section!

Iasmim Cristina

Frontend developer and UX enthusiast. Passionate about expanding my knowledge through projects, tools, and broad perspectives.

View all posts by Iasmim Cristina →