Nadia Makarevich

Why custom react hooks could destroy your app performance

Why custom react hooks could destroy your app performance

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 pretty good introduction 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.

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:

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:

<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:

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:

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! 😍 See this beauty in codesandbox. Only don’t rush to use it in your apps right away, not until you read about its dark side 😅

Performance implications

In the previous article, 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. Here is the codesandbox, 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).

How to throttle CPU: 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:

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:

You probably already guessed the result 🙂 The slowest appearance of 2 empty divs ever existed 😱. See the codesandbox.

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.

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:

const SettingsButton = () => {
const { Dialog, open } = useModal();
return (
<>
<button onClick={open}>Open settings</button>
<Dialog />
</>
);
};

And in Page just render the SettingsButton:

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 codesandbox with the solution.

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:

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. See codesandbox.

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. We know already, 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.

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! See the codesandbox. 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:

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?

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

You know the best (or the scariest) part of this? IT DIDN’T! 😱 See the codesandbox.

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 😔.

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],
);
};

See the codesandbox.

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:

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:

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. See the codesandbox.

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! ✌🏼