Hooks launched in React with v16.8 nearly 2 years ago, enabling us to use state and other React features without writing a class. After getting a handle of Hooks, I've found Hooks-based components to be more approachable than their class-based counterparts. This has been the same in my minishops as well. Those taking the Zero to React with Hooks Minishop seem to pick up Hooks faster than those in the Migrating to React Hooks Minishop.
But no matter how good an abstraction is, there are always abstraction leaks and it's no different with React Hooks. I alluded to one in my post on dealing with helper functions in the useEffect
Hook. Another one of those leaks is that we can only call Hooks at the top level of our React function components.
We can't call Hooks inside of conditionals, loops, or nested functions in order to ensure that Hooks are called in the same order each time a component renders. The order is important for how React associates Hook calls with components. So if we conditionally render a Hook, for instance, the order of the Hooks could change between renders of a component, completely messing up the Hooks system.
Sometimes, though, despite the rules, we do want to conditionally call a React Hook, especially when we don't have access to the implementation of a custom Hook. Let's learn how to be rule breakers. 😎
Let's say we're using the awesome react-use
collection of React Hooks in our app. And we want to use the useClickAway
Hook in order to hide an overlay when the user clicks outside of its container.
const Overlay = ({ show, children, onClose }) => {
if (!show) {
return null
}
const rootRef = useRef(null)
useClickAway(rootRef, () => {
console.log('clicked outside')
onClose()
})
return (
<div ref={rootRef}>
{children}
<CloseButton onClick={onClose} />
</div>
)
}
This code is actually incorrect. Can you spot the problem? It seems perfectly fine because the useRef
and useClickAway
are called at the top level, but it still breaks that rule of Hooks. With the eslint-plugin-react-hooks
set up in our ESLint configuration, we're warned of the error by the react-hooks/rules-of-hooks
ESLint rule:
React Hook "useClickAway" is called conditionally. React Hooks must be called
in the exact same order in every component render. Did you accidentally
call a React Hook after an early return?
Thanks ESLint! Although the Hooks are called at the top level, they are still conditionally called because they won't be called if the show
prop is false
.
So how do we solve this problem? We can't change the implementation of useClickAway
to take an additional enabled
parameter, so how can we avoid breaking Hooks yet make our Overlay
component work as it should?
Call but ignore
One way to work around the rule is to use what I call the "call but ignore" approach. With this workaround, we always call the Hook, but we do nothing with it.
const Overlay = ({ show, children, onClose }) => {
const rootRef = useRef(null)
// `useClickAway` is always called, but we only
// use the callback when `show` is `true`
useClickAway(rootRef, () => {
if (show) {
console.log('clicked outside')
onClose()
}
})
// early return moves to after Hook calls
if (!show) {
return null
}
return (
<div ref={rootRef}>
{children}
<CloseButton onClick={onClose} />
</div>
)
}
This looks pretty similar to the original code, except we always call the Hooks now. The "early return" is not so early. 😂 The rootRef
will always get created, but will never be attached to any DOM. And then within useClickAway
, instead of always calling onClose
, we only call onClose
when the show
prop is true
just to make doubly sure.
This works, and is usually the simplest approach, but it can be wasteful. Creating a bunch of refs with useRef
is likely not that big of a deal, but calling useClickAway
unnecessarily is more so. The implementation of useClickAway
adds mousedown
and touchstart
events to the document
, which now are getting added whether or not the Overlay
is being shown.
The "call but ignore" approach doesn't always work either. Let's say we're using the useTitle
Hook from react-use
. It's a side-effect hook that sets the title of the page (aka document.title
). But we do not want it to set the page title when the value is null
or undefined
:
const Example = ({ team }) => {
if (team.name !== null && team.name !== undefined) {
useTitle(team.name)
}
return (
<section>
<h1>{team.name}</h1>
{ ... }
</section>
)
}
This is a more obvious conditional call of a Hook, but there's no clear way to use the "call but ignore" approach, because there's nothing to ignore. We'd have to implement our own useTitle
Hook in order to get it to ignore null
and undefined
values.
Renderless component wrapper
When the "call but ignore" workaround doesn't work, we can use the "renderless" component wrapper workaround. We wrap our Hook with a component interface and then conditionally render the component. This requires more work, but on the flip side it always should work.
So for our useClickAway
Hook, we can create a ClickAway
component that renders nothing, and we then conditionally render it:
const ClickAway = ({ ref, onClickAway }) => {
useClickAway(ref, onClickAway)
return null
}
const Overlay = ({ show, children, onClose }) => {
const rootRef = useRef(null)
if (!show) {
return null
}
return (
<div ref={rootRef}>
<ClickAway
ref={rootRef}
onClickAway={() => {
console.log('clicked outside')
onClose()
}}
/>
{children}
<CloseButton onClick={onClose} />
</div>
)
}
Now, instead of trying to conditionally call the useClickAway
Hook, we're conditionally rendering the <ClickAway>
component, and there is no rule against that. The ClickAway
component itself always calls useClickAway
, but since it is being conditionally rendered within Overlay
, useClickAway
is indirectly conditionally called! 🎉 In this case, the ClickAway
component's props match the arguments that the useClickAway
Hook accepts.
To conditionally call the useTitle
Hook, we can take a similar approach:
const PageTitle = ({ title }) => {
useTitle(title)
return null
}
const Example = ({ team }) => {
return (
<section>
{team.name && <PageTitle title={team.name} />}
<h1>{team.name}</h1>
{ ... }
</section>
)
}
So again, the PageTitle
component is a "renderless" component by returning null
. And it only calls the useTitle
Hook with the title
prop. And now that Example
has a component to work with, it can conditionally render <PageTitle>
based on team.name
not being null
or undefined
.
How about one more example before we wrap up? Both the useClickAway
and useTitle
Hooks don't return any data so their "renderless" component wrappers have rather simple interfaces. However, if you have a Hook that returns data AND you still want to conditionally render it, your component wrapper will still be "renderless," but in a slightly different way.
Let's say we're using the useWindowScroll
Hook to display a "back to top" button if the user has scrolled down the page enough. And like the others, this functionality is conditional based on a prop. We'll have to turn our "renderless" component wrapper into a component with render prop:
const WindowScroll = ({ children }) => {
const { x, y } = useWindowScroll()
return <>{children({ x, y })}</>
}
const Page = ({ showBackToTop }) => {
return (
<div>
{showBackToTop && (
<WindowScroll>
{(pos) => {
const shouldShow = pos.y > 250
return (
<button className={shouldShow ? 'sticky' : 'hidden'}>
Back to top
</button>
)
}}
</WindowScroll>
)}
<main>
{...}
</main>
</div>
)
}
The WindowScroll
component provides a children
render prop that will contain the x
& y
coordinates of the window
that it gets from useWindowScroll
. Instead of returning null
to be "renderless", it renders the UI returned by the children
render prop. In this example, that UI is the "back to top" button. But the entire <WindowScroll>
component is conditionally rendered based on the showBackToTop
prop. So we are once again successfully able to conditionally call the useWindowScroll
Hook through the WindowScroll
component.
Honestly, I don't often need to call Hooks conditionally, especially Hooks that return data. However, when that need does arise, having these two workarounds in my tool belt helps me push past my temporary frustration with the Hooks abstraction. Then I return to enjoying Hooks again. 🤗
These are the two approaches that I've found to solve the conditional Hooks problem. But I'm curious if there are other ways to work around the issue. If you have your own, feel free to reach out to me on Twitter at @benmvp and let me know!
Keep learning my friends. 🤓