Effective Higher-Order Components
Before the introduction of contexts and hooks in React v16.8, Higher-Order Components (or HOCs for short) were a common sight. Today, it is an under-used pattern.
HOCs are Wrappers⌗
You've Got Mail!
No more than five emails per year, all densely packed with knowledge.
A Higher-Order Component is a play on the functional programming concept of a High-Order Function. It is a function that accepts a component and returns it wrapped in additional logic.
While the concept presents infinite possibilities, practical applications should be limited to transparently adding wrappers or logic. Transparently means that HOCs should avoid surprising behavior: no modifying the component or the props it receives.
Conventions⌗
HOC conventions suggest three general rules:
-
A HOC should not change the API of a provided component
A prop passed from the parent should reach the underlying component. Forwardining props is easy to do by using
{...props}
after any additional props. -
HOCs should have a name following the
withNoun
patternFor example
withPropsLogger
orwithThemeProvider
. -
HOCs should not have any parameters aside from the Component itself. HOCs can handle additional parameters via currying.
A consistent HOC signature is important for their composability.
👍 Correct signaturewithNoun("some data")(MyComponent)
👎 Wrong order of callswithNoun(MyComponent)("some data")
👎 Missing curryingwithNoun("some data", MyComponent)
-
(Bonus) Provide a
DisplayName
:While not a necessary development pattern, providing a custom
DisplayName
is a useful pattern for debugging. Adding it will make error messages and React Dev Tools more helpful in identifying issues in HOCs.function withSubscription(WrappedComponent) { class WithSubscription extends React.Component {/* ... */} WithSubscription.displayName = `WithSubscription(${getDisplayName(WrappedComponent)})`; return WithSubscription; } function getDisplayName(WrappedComponent) { return WrappedComponent.displayName || WrappedComponent.name || 'Component'; }
HOCs are Easy⌗
The type signature of a HOC is generic: it accepts the same props as the component it wraps.
interface HOC<T> {
(Component: React.ComponentType<T>): (props: T) => JSX.Element
}
Some of the most common examples of HOCs involve…
Props logging:
const withPropsLogger = (Component) => (
(props) => {
console.log(props);
return <Component {...props}/>
};
);
Adding formatting, notice the currying pattern:
const withExtraPadding = (padding) => (Component) => (
(props) => (
<div styles={{ padding }}>
<Component {...props}/>
</div>
)
);
Adding Context Providers, once more with the currying pattern:
const withMyContext = (myContextValue) => (Component) => (
(props) => (
<MyContext.Provider value={myContextValue}>
<Component {...props}/>
</MyContext.Provider>
)
);
HOCs are Composable⌗
Each of the HOCs above can be used on a component both individually or in combination.
const MyWrappedComponent = withPropsLogger(
withMyContext(5)(
withExtraPadding(20)(
MyComponent
)
)
);
Combining them without functional tools makes it difficult to read. Importing reduceHOCs
and applyHOCs
functions helps a lot with clarity.
const MyWrappedComponent = applyHOCs(
withPropsLogger,
withMyContext(5),
withExtraPadding(20),
)(MyComponent);
The Don’ts⌗
Don’t use HOCs Inline⌗
React keeps track of the component state using a combination of component object identity and its location in the React virtual DOM. Changing either the identity or the location will cause a loss of state.
export default function App() {
// Create a function with a new identity on every render
// which makes it lose state track of internal
const ClickCounterWithLogger = withPropsLogger(ClickCounter);
return (
<ClickCounterWithLogger />
);
}
Interactive example on CodeSandbox
Don’t use Conditional Wrappers⌗
Similar to inline HOCs, if the tree produced by a component changes in structure, such as a wrapper removal, React will be unable to persist child states.
Don’t use HOCs for Data⌗
Another example of situations for which HOCs are not suitable for are data providers:
function withData(Component) {
const { data, isLoading } = useApi();
return props => <Component
data={data}
isLoading={isLoading}
{...props}
/>
}
The data provider HOC pattern was formerly a common use case for HOCs in React. The highest profile example is Redux’s connect
HOC, which now has a Tip at the top of the page containing the following:
connect
still works and is supported in React-Redux 8.x. However, we recommend using the hooks API as the default.
At the time, it was a pattern that gathered mixed feelings and metaphorical discussions on whether “tails should wag dogs." Hooks have superceded this practice.
Don’t be Clever⌗
You've Got Mail!
No more than five emails per year, all densely packed with knowledge.
Armed with a refreshed interest in HOCs, remember that HOCs are a tool to lessen the mental load of JSX. They are not an opportunity to show off your functional programming skills.
Deciding when is an appropriate time for HOCs is a judgment call<> much like determining when to use contexts, and often comes down to opinion. With that said, I am firm on a few cases when you should not use HOCs.
One such case is when HOCs implement logic that should be owned by either the component itself or its parent. For example, defaulting props:
// A parent component is responsible for supplying
// default props, or the child component should
// be handling it.
withDefaultValueForProp(MyComponent)({
field: "data",
defaultData: [1, 2, 3]
});
The above creates logic gaps that are difficult to modify if you end up needing something more granular. What if MyComponent
is changed to either accept a data={...}
or an isLoading
prop? The HOC will need to go. Any HOC doing too much is destined do nothing at all.
A more dramatic case is implementing dynamic logic in the HOC call.
withPropTransformation(MyComponent)(props => {
return {
...props,
name: props.name.toUpperCase()
}
});
HOCs should be reserved when the logic is so minimal that you feel comfortable approaching with an “out of sight, out of mind” mentality.
Additional Information for Library Components⌗
I received comments about this post telling me that I didn’t go deep enough into the weeds of HOCs that could potentially bite library authors. I didn’t because this post is Effective HOCs, not Everything You Need To Know About HOCs (maybe one day).
Nonetheless, I will go through some of the more common edge-cases HOC authors need to think about. By no means is the following exhaustive!
A Note on Refs⌗
If you need to wrap a component that uses ref
in a HOC, including a React.forwardRef
call will make it work.
const withPropsLogger = (Component) => {
return React.forwardRef((props, ref) => {
console.log(props);
return <Component {...props} ref={ref} />
});
}
I haven’t found a method to add types to a HOC while supporting components simultaneously with and without forwardRef
.
type HOCForRefComponent<T, P extends {}> = React.ForwardRefExoticComponent<React.PropsWithoutRef<P> & React.RefAttributes<T>>;
const withPropsLoggerWithRef = <T, P extends {}>(
Component: HOCForRefComponent<T, P>
): HOCForRefComponent<T, P> => React.forwardRef<T, P>(
(props, ref) => <Component {...props as React.PropsWithoutRef<P>} ref={ref} />
)
Another approach is avoiding the ref
keyword altogether when it comes to HOCs and using a prop name like innerRef
.
There are a few other edge cases discussed in the React documentation on refs and HOCs, but it focuses on class components.
A Note on Static Properties⌗
In my eyes, static properties are an anti-pattern but I digress. In the context of HOCs, static properties are not automatically passed along.
You need explicitly pass each static property to your HOC’d component.
Addressing Comments⌗
This post has received some traction on Reddit. I’m pretty happy about the attention, but most of the commenters are clearly not very happy about HOCs. Many believe some variation of “hooks good, HOCs bad”.
Hooks > HOCs⌗
While I appreciate this post keeping the art of the HOC alive and I really like their dos and donts, I still think hooks are a significantly better option 99.9% of the time. HOCs a very easy way to add surprising behavior to components that might confound future developers. Hooks basically killed them because they are a more predictable, explicit pattern. – /u/sickcodebruh420
I agree: hooks did kill HOCs but only because HOCs used to be the only way of providing app-level data without prop-drilling. Data-provider HOCs were “99.9%" of them and seemingly traumatized developers to the point that they never want to see a HOC again.
Good HOCs exist, and provide powerful patterns which can help write clear and succinct expressions in lieu of JSX.
const withCommon = reduceHOCs(
withErrorBoundary,
withThemeProvider,
);
const withGlobal = reduceHOCs(
withQueryClientProvider,
withStoreProvider,
);
applyHOCs(
withCommon,
withGlobal,
)(App);
applyHOCs(
withCommon
)(Page)
(Not Quite) Everything About HOCs⌗
Another comment suggested that I didn’t cover HOCs thoroughly enough and brought up a few points:
Just wanted to add my two cents if somebody is making library that is built around HOC (not sure why?) is to ensure these things:
- HOC must pass static properties (usehoist-non-react-statics
)
- HOC must have two versions: one that passes ref and other that is not.
- It would be nice if HOC would set nicedisplayName
– /u/Reeywhaar
The post has been updated to reflect these points. I suggest reading the whole thread with /u/Reeywhaar.