If you've written React code recently, you've probably used some official React hooks like useState
or useEffect
. In this post, we'll look at what higher-order components are and when it can help us eliminate some extra boilerplate vs hooks.
Analytics example
For a lot of products, you'll want to add some sort of tracking of key events. What pages are my users visiting, where are my users spending the most time, etc. Let's say we have some function recordEvent(eventName)
which will save the event to our analytics store.
Here's a simple example page where we're recording an event on the user's initial page load and every 30 seconds with recordEvent
:
const HelpPage = () => {
// On initial load, record an event
useEffect(() => {
recordEvent("VISIT_HELP_PAGE")
}, [])
// Every 30 seconds, record another event if the page itself is not hidden
useEffect(() => {
const interval = setInterval(() => {
if (!document.hidden) {
recordEvent("STILL_ON_HELP_PAGE")
}
}, 30000);
return () => clearInterval(interval);
}, []);
return <div>{/* Render the page */}</div>
}
export default HelpPage
If we want to re-use this functionality across other components, we can make a custom hook:
// useAnalytics.js
function useAnalytics(initialEventName, periodicEventName) {
// On initial load, record an event
useEffect(() => {
recordEvent(initialEventName)
}, [])
// Every 30 seconds, record another event if the page itself is not hidden
useEffect(() => {
const interval = setInterval(() => {
if (!document.hidden) {
recordEvent(periodicEventName)
}
}, 30000);
return () => clearInterval(interval);
}, []);
}
// HelpPage.js
const HelpPage = () => {
useAnalytics("VISIT_HELP_PAGE", "STILL_ON_HELP_PAGE")
return <div>{/* Render the page */}</div>
}
export default HelpPage;
Another option, is to use a higher-order component. The idea behind a higher-order component is we have a function that takes in a component and returns a new component. In our analytics example, we'll take in our HelpPage
component, and return a new component with our two useEffect
calls at the top:
function withAnalytics(WrappedComponent, initialEventName, periodicEventName) {
const ComponentWithAnalytics = (props) => {
// On initial load, record an event
useEffect(() => {
recordEvent(initialEventName)
}, [])
// ...etc
// Make sure to pass the props along
return <WrappedComponent {...props} />
}
// Convention: Wrap the display name
ComponentWithAnalytics.displayName = `WithAnalytics(${getDisplayName(WrappedComponent)})`;
return ComponentWithAnalytics
}
function getDisplayName(WrappedComponent) {
return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}
This allows us to write:
const HelpPage = () => {
return <div>{/* Render the page */}</div>
}
const HelpPageWithAnalytics = withAnalytics(HelpPage, "VISIT_HELP_PAGE", "STILL_ON_HELP_PAGE");
export default HelpPageWithAnalytics
Comparing these two code snippets, the final result has a similar amount of code. However, higher-order components come with some additional things to worry about like:
- Passing props down to our WrappedComponent
- Creating a good display name for our new component for debugging purposes
- Copying static methods over
- Forwarding refs along
There are libraries like hoist-non-react-statics which help reduce some of these pain points, but in this example, I'd prefer to just use the hook. Let's look at some examples where a higher-order component is more appealing.
Creating a higher-order component around useSWR
The biggest advantage of a higher-order component is that it can return whatever component it wants. If you want to return a loading spinner or error message instead of the wrapped component, you can do that.
Another advantage is it can select which props (or create new props) to be passed to the wrapped component. To see these in action, let's build a higher-order component around useSWR.
Here's a minimal example from SWR's website, where we fetch user information from an API and render it:
import useSWR from 'swr'
function Profile() {
const { data, error } = useSWR('/api/user', fetcher)
if (error) return <div>failed to load</div>
if (!data) return <div>loading...</div>
return <div>hello {data.name}!</div>
}
Now, let's look at how this code could look with a higher-order component:
function ProfileInner({data}) {
return <div>hello {data.name}!</div>
}
const Profile = withSWR(ProfileInner, '/api/user')
Without showing withSWR
, what is it taking care of for us? The most obvious thing is that it must be making the call to useSWR
for us. We also no longer have an error
, which means it is handling displaying the error. Similarly, we don't seem to have a loading message, so it must take care of that too.
By hiding the error and loading in withSWR
, it does two things for us:
- We only have to worry about displaying the result in the successful case
- We have no control over how errors and loading messages look for Profile
We can fix 2 by providing ways to display an error or a loading message, like so:
function ProfileInner({data}) {
return <div>hello {data.name}!</div>
}
const Profile = withSWR(ProfileInner, '/api/user', {
loadingComponent: <div>loading...</div>,
errorComponent: <div>failed to load</div>
})
and this is fine, but we are back to taking on the complexities associated with a higher-order component, and we're still writing a similar amount of code to the hook case.
When would we choose a higher-order component over a hook?
Personally, I think one of the strongest cases for using a higher-order component is when you have a consistent loading or error component across your application. withSWR
above is really appealing if we use the same loading spinner everywhere. It can save a lot of boilerplate from the hook cases, so you don't have to keep writing if statements after hooks.
Additionally, class components do not support hooks. If you are using class components and want to use a hook, your best option is to create a functional higher-order component which calls the hook and passes down props to your class component.
Practically speaking, I tend to make hooks first. If I find myself writing a lot of extra boilerplate code on top of the hook, then I'll make a higher-order component, and often that component will use the hook itself!