Why custom react hooks could destroy your app performance
Tags: react, performance, webdev, javascript
Published: 2022-01-24 08:49:37

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y9d2ys6t8v55ah82fz3b.png)


Originally published at [https://www.developerway.com](https://www.developerway.com). The website has more articles like this 😉

---

Scary title, isn’t it? The sad part is that it’s true: for performance-sensitive apps custom React hooks can very easily turn into the biggest performance killer, if not written and used very carefully.

I’m not going to explain how to build and use hooks here, if you never built a hook before, the React docs have a <a href="https://reactjs.org/docs/hooks-custom.html">pretty good introduction</a> into it. What I want to focus on today is their performance implication for complicated apps.

## Let’s build a modal dialog on custom hooks

Essentially, hooks are just advanced functions that allow developers to use things like state and context without creating new components. They are super useful when you need to share the same piece of logic that needs state between different parts of the app. With hooks came a new era in React development: never before our components were as slim and neat as with hooks, and separation of different concerns was as easy to achieve as with hooks.

Let’s for example, implement a modal dialog. With custom hooks, we can create a piece of beauty here.

First, let’s implement a “base” component, that doesn’t have any state, but just renders the dialog when `isOpen` prop is provided and triggers `onClose` callback when a click on a blanket underneath the dialog happens.

```tsx
type ModalProps = {
  isOpen: boolean;
  onClosed: () => void;
};

export const ModalBase = ({ isOpen, onClosed }: ModalProps) => {
  return isOpen ? (
    <>
      <div css={modalBlanketCss} onClick={onClosed} />
      <div css={modalBodyCss}>Modal dialog content</div>
    </>
  ) : null;
};
```

Now to the state management, i.e. the “open dialog/close dialog” logic. In the “old” way we would usually implement a “smart” version of it, which handles the state management and accepts a component that is supposed to trigger the opening of the dialog as a prop. Something like this:

```tsx
export const ModalDialog = ({ trigger }) => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <div onClick={() => setIsOpen(true)}>{trigger}</div>
      <ModalBase isOpen={isOpen} onClosed={() => setIsOpen(false)} />
    </>
  );
};
```

Which then will be used like this:

```tsx
<ModalDialog trigger={<button>Click me</button>} />
```

This is not a particularly pretty solution, we’re messing with the position and accessibility of the trigger component inside our modal dialog by wrapping it in a div. Not to mention that this unnecessary div will result in a slightly larger and messier DOM.

And now watch the magic. If we extract the “open/close” logic into a custom hook, render this component **inside** the hook, and expose API to control it as a return value from the hook, we can have the best of both worlds. In the hook we’ll have the “smart” dialog that handles its own state, but doesn’t mess with the trigger nor does it need one:

```tsx
export const useModal = () => {
  const [isOpen, setIsOpen] = useState(false);

  const open = () => setIsOpen(true);
  const close = () => setIsOpen(false);
  const Dialog = () => <ModalBase onClosed={close} isOpen={isOpen} />;

  return { isOpen, Dialog, open, close };
};
```

And on the consumer side we’ll have a minimal amount of code while having the full control over what triggers the dialog:

```tsx
const ConsumerComponent = () => {
  const { Dialog, open } = useModal();

  return (
    <>
      <button onClick={open}>Click me</button>
      <Dialog />
    </>
  );
};
```

If this isn’t perfection, I don’t know what is! 😍 <a href="https://codesandbox.io/s/modal-dialog-example1-9ds2c?file=/src/App.tsx">See this beauty in codesandbox</a>. Only don’t rush to use it in your apps right away, not until you read about its dark side 😅

## Performance implications

In the <a href="https://developerway.com/posts/how-to-write-performant-react-code">previous article</a>, where I covered in detail various patterns that lead to poor performance, I implemented a “slow” app: just a simple not optimized list of ~250 countries rendered on the page. But every interaction there causes the entire page to re-render, which makes it probably the slowest simple list ever existed. <a href="https://codesandbox.io/s/re-renders-final-bad-4znwe">Here is the codesandbox</a>, click on different countries in the list to see what I mean (if you’re on the latest Mac throttle your CPU a bit to get a better impression).


>   <b>How to throttle CPU</b>: in Chrome developer tools open “Performance” tab, and click on the “cog wheel” icon in the top right corner -
>   it will open a small additional panel with throttling options.


I’m going to use our new perfect modal dialog there and see what happens. The code of the main `Page` component is relatively simple and looks like this:

```tsx
export const Page = ({ countries }: { countries: Country[] }) => {
  const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]);
  const [savedCountry, setSavedCountry] = useState<Country>(countries[0]);
  const [mode, setMode] = useState<Mode>('light');

  return (
    <ThemeProvider value={{ mode }}>
      <h1>Country settings</h1>
      <button onClick={() => setMode(mode === 'light' ? 'dark' : 'light')}>Toggle theme</button>
      <div className="content">
        <CountriesList countries={countries} onCountryChanged={(c) => setSelectedCountry(c)} savedCountry={savedCountry} />
        <SelectedCountry country={selectedCountry} onCountrySaved={() => setSavedCountry(selectedCountry)} />
      </div>
    </ThemeProvider>
  );
};
```

And now I need a button near the “Toggle theme” button that would open a modal dialog with some future additional settings for this page. Luckily, now it can’t be simpler: add `useModal` hook at the top, add the button where it needs to be, and pass `open` callback to the button. The `Page` component barely changes and is still quite simple:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6kkynx9hikon0mnn8g3i.png)


You probably already guessed the result 🙂 The slowest appearance of 2 empty divs ever existed 😱. <a href="https://codesandbox.io/s/re-renders-bad-with-dialog-6egnq?file=/src/country-settings/page.tsx">See the codesandbox.</a>

You see, what is happening here, is our `useModal` hook uses state. And as we know, state changes are one of the reasons why a component would re-render itself. This also applies to hooks - if the hook's state changes, the "host" component will re-render. And it makes total sense. If we look closely inside `useModal` hook, we’ll see that it’s just a nice abstraction around `setState`, it exists **outside** of the `Dialog` component. Essentially it’s no different than calling `setState` in the `Page` component directly.

And this is where the big danger of hooks is: yes, they help us make the API really nice. But what we did as a result, and the way of hooks is pretty much encouraging it, is essentially **lifted state up** from where it was supposed to be. And it’s entirely not noticeable unless you go inside the `useModal` implementation or have lots of experience with hooks and re-renders. I’m not even using the state directly in `Page` component, all I'm doing from its perspective is rendering a `Dialog` component and calling an imperative API to open it.

In the “old world”, the state would’ve been encapsulated in the slightly ugly `Modal` dialog with the `trigger` prop, and the `Page` component would’ve stayed intact when the button is clicked. Now the click on the button changes the state of **the entire Page component**, which causes it to re-render (which is super slow for this app). And the dialog can only appear when React is done with all the re-renders it caused, hence the big delay.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ycwdve22u34dgq81cz6a.png)

So, what can we do about it? We probably won’t have time and resources to fix the underlying performance of the `Page` component, as it would usually happen with the “real” apps. But at least we can make sure that the new feature doesn’t add to the performance problems and is fast by itself. All that we need to do here is just move the modal state “down”, away from the slow `Page` component:

```tsx
const SettingsButton = () => {
  const { Dialog, open } = useModal();

  return (
    <>
      <button onClick={open}>Open settings</button>
      <Dialog />
    </>
  );
};
```

And in `Page` just render the `SettingsButton`:

```tsx
export const Page = ({ countries }: { countries: Country[] }) => {
  // same as original page state
  return (
    <ThemeProvider value={{ mode }}>
      // stays the same
      <SettingsButton />
      // stays the same
    </ThemeProvider>
  );
};
```

Now, when the button is clicked, only `SettingsButton` component will re-render, the slow `Page` component is unaffected. Essentially, we’re imitating the state model as it would’ve been in the “old” world while preserving the nice hooks-based API. See the <a href="https://codesandbox.io/s/re-renders-bad-with-dialog-fixed-rrfey?file=/src/country-settings/page.tsx">codesandbox</a> with the solution.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rdodquvil85v7hble1xt.png)

## Adding more functionality to the `useModal` hook

Let’s make our hooks performance conversation slightly darker 🙂. Imagine, for example, you need to track the scroll event in the modal content. Maybe you want to send some analytics events when the users scroll through the text, to track reads. What will happen if I don’t want to introduce “smart” functionality to the `BaseModal` and do it in the `useModal` hook?

Relatively easy to achieve. We can just introduce a new state there to track scroll position, add event listeners in `useEffect` hook and pass ref to the `BaseModal` to get the content element to attach the listeners to. Something like this:

```tsx
export const ModalBase = React.forwardRef(({ isOpen, onClosed }: ModalProps, ref: RefObject<any>) => {
  return isOpen ? (
    <>
      <div css={modalBlanketCss} onClick={onClosed} />
      <div css={modalBodyCss} ref={ref}>
        // add a lot of content here
      </div>
    </>
  ) : null;
});

export const useModal = () => {
  const [isOpen, setIsOpen] = useState(false);
  const ref = useRef<HTMLElement>(null);
  const [scroll, setScroll] = useState(0);

  // same as before

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const handleScroll = () => {
      setScroll(element?.scrollTop || 0);
    };

    element.addEventListener('scroll', handleScroll);
    return () => {
      element.removeEventListener('scroll', handleScroll);
    };
  });

  const Dialog = () => <ModalBase onClosed={close} isOpen={isOpen} ref={ref} />;

  return {
    isOpen,
    Dialog,
    open,
    close,
  };
};
```

And now we can do whatever with this state. Now let’s pretend that the previous performance problems are not that big of a deal, and use again this hook directly in the slow Page component. <a href="https://codesandbox.io/s/re-renders-bad-with-dialog-with-scroll-p9wi8?file=/src/country-settings/page.tsx">See codesandbox</a>.

The scrolling doesn’t even work properly! 😱 Every time I try to scroll the dialog content it resets to the top!

Okay, let’s think logically. <a href="https://www.developerway.com/posts/how-to-write-performant-react-code">We know already</a>, that creating components inside render functions is evil, since React will re-create and re-mount them on every re-render. And we know that hooks change with every state change. That means now, when we introduced scroll state, on every scroll change we’re changing state, which causes the hook to re-render, which causes `Dialog` component to re-create itself. Exactly the same problem, as with creating components inside render functions, with exactly the same fix: we need to extract this component outside of the hook or just memoise it.

```tsx
const Dialog = useMemo(() => {
  return () => <ModalBase onClosed={close} isOpen={isOpen} ref={ref} />;
}, [isOpen]);
```

The focus behaviour is fixed, but there is another problem here: the slow `Page` component re-renders on every scroll! That one is a bit hard to notice since the dialog content is just text. Try, for example, to reduce the CPU by 6x, scroll, and then just highlight the text in the dialog right away. The browser won’t even allow that, since it’s too busy with re-renders of the underneath `Page` component! <a href="https://codesandbox.io/s/re-renders-bad-with-dialog-with-scroll-fixed-scroll-0s5g3?file=/src/country-settings/page.tsx">See the codesandbox.</a> And after a few scrolls, your laptop will probably try to take off to the Moon due to 100% CPU load 😅

Yeah, we definitely need to fix that before releasing it to production. Let’s take another look at our component, especially at this part:

```tsx
return {
  isOpen,
  Dialog,
  open,
  close,
};
```

We’re returning a new object on every re-render, and since we re-render our hook on every scroll now, that means that object changes on every scroll as well. But we’re not using the scroll state here, it’s entirely internal for the `useModal` hook. Surely just memoising that object will solve the problem?

```tsx
return useMemo(
  () => ({
    isOpen,
    Dialog,
    open,
    close,
  }),
  [isOpen, Dialog],
);
```

You know the best (or the scariest) part of this? IT DIDN’T! 😱 <a href="https://codesandbox.io/s/re-renders-bad-with-dialog-with-scroll-fixed-scroll-and-memo-35lqx?file=/src/country-settings/modal-dialog.tsx">See the codesandbox</a>.

And this is another huge performance-related bummer with hooks. Turns out, it doesn’t really matter, whether the state change in hooks is “internal” or not. **Every state change in a hook, whether it affects its return value or not, will cause the “host” component to re-render.**

And of course exactly the same story with chaining hooks: if a hook’s state changes, it will cause its “host” hook change as well, which will propagate up through the whole chain of hooks until it reaches the “host” component and re-renders it (which will cause another chain reaction of re-renders, only downstream now), **regardless of any memoisation** applied in between.

Extracting the “scrolling” functionality into a hook will make absolutely no difference, the slow Page component will re-render 😔.

```tsx
const useScroll = (ref: RefObject) => {
  const [scroll, setScroll] = useState(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const handleScroll = () => {
      setScroll(element?.scrollTop || 0);
    };

    element.addEventListener('scroll', handleScroll);
    return () => {
      element.removeEventListener('scroll', handleScroll);
    };
  });

  return scroll;
};

export const useModal = () => {
  const [isOpen, setIsOpen] = useState(false);
  const ref = useRef<HTMLElement>(null);
  const scroll = useScroll(ref);

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

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

  const Dialog = useMemo(() => {
    return () => <ModalBase onClosed={close} isOpen={isOpen} ref={ref} />;
  }, [isOpen, close]);

  return useMemo(
    () => ({
      isOpen,
      Dialog,
      open,
      close,
    }),
    [isOpen, Dialog, open, close],
  );
};
```

<a href="https://codesandbox.io/s/re-renders-bad-with-dialog-with-scroll-extracted-woeer?file=/src/country-settings/modal-dialog.tsx">See the codesandbox.</a>

How to fix it? Well, the only thing to do here is to move the scroll tracking hook outside of the `useModal` hook and use it somewhere where it won’t cause the chain of re-renders. Can introduce `ModalBaseWithAnalytics` component for example:

```tsx
const ModalBaseWithAnalytics = (props: ModalProps) => {
  const ref = useRef<HTMLElement>(null);
  const scroll = useScroll(ref);

  console.log(scroll);

  return <ModalBase {...props} ref={ref} />;
};
```

And then use it in the `useModal` hook instead of the `ModalBase`:

```tsx
export const useModal = () => {
  // the rest is the same as in the original useModal hook

  const Dialog = useMemo(() => {
    return () => <ModalBaseWithAnalytics onClosed={close} isOpen={isOpen} ref={ref} />;
  }, [isOpen, close]);

  return useMemo(
    () => ({
      isOpen,
      Dialog,
      open,
      close,
    }),
    [isOpen, Dialog, open, close],
  );
};
```

Now the state changes due to the scrolling will be scoped to the `ModalBaseWithAnalytics` component and won’t affect the slow `Page` component. <a href="https://codesandbox.io/s/re-renders-bad-with-dialog-scroll-fixed-for-good-v6ohp?file=/src/country-settings/modal-dialog.tsx">See the codesandbox.</a>

That is all for today! Hope this article 
scared you enough
 helped you to feel more comfortable with custom hooks and how to write and use them without compromising the performance of your apps. Let’s recap the rules of performant hooks before leaving:

- every state change in a hook will cause its “host” component to re-render, regardless of whether this state is returned in the hook value and memoised or not
- the same with chained hooks, every state change in a hook will cause all “parent” hooks to change until it reaches the “host” component, which again will trigger the re-render

And the things to watch out for, when writing or using custom hooks:

- when using a custom hook, make sure that the state that this hook encapsulates is not used on the level it wouldn’t have been used with the components approach. Move it “down” to a smaller component if necessary
- never implement “independent” state in a hook or use hooks with the independent state
- when using a custom hook, make sure it doesn’t perform some independent state operations, that are not exposed in its return value
- when using a custom hook, make sure that all hooks that it uses also follow the rules from the above

Stay safe and may your apps be blazing fast from now on! ✌🏼

...

Originally published at [https://www.developerway.com](https://www.developerway.com). The website has more articles like this 😉

[Subscribe to the newsletter](https://www.developerway.com), [connect on LinkedIn](https://www.linkedin.com/in/adevnadia/) or [follow on Twitter](https://twitter.com/adevnadia) to get notified as soon as the next article comes out.