Why custom react hooks could destroy your app performance
Nadia Makarevich
Posted on January 24, 2022
Originally published at 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 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],
);
};
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! âđŒ
...
Originally published at https://www.developerway.com. The website has more articles like this đ
Subscribe to the newsletter, connect on LinkedIn or follow on Twitter to get notified as soon as the next article comes out.
Posted on January 24, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.