Originally published at https://www.developerway.com. The website has more articles like this đ
Is it true that React hooks made higher-order components obsolete? And the only use case for those is to be a remnant of the past in some existential legacy corners of our apps? And what is a higher-order component anyway? Why did we need them in the first place?
Answering those questions and building a case that higher-order components are still useful even in modern apps for certain types of tasks.
But let's start from the beginning.
What is a higher-order component?
According to React docs, itâs an advanced technique to re-use components logic that is used for cross-cutting concerns, if that description means anything to you (for me not so much đ).
In English, itâs just a function, that accepts a component as one of its arguments, messes with it, and then returns back its changed version. The simplest variant of it, that does nothing, is this:
// accept a Component as an argument
const withSomeLogic = (Component) => {
// do something
// return a component that renders the component from the argument
return (props) => <Component {...props} />;
};
The key here is the return part of the function - itâs just a component, like any other component. And similar to the render props pattern, we need to pass props to the returned component, otherwise, they will be swallowed.
And then, when itâs time to use it, it would look like this:
const Button = ({ onClick }) => <button onClick={func}>Button</button>;
const ButtonWithSomeLogic = withSomeLogic(Button);
You pass your Button
component to the function, and it returns the new Button
, which includes whatever logic is defined in the higher-order component. And then this button can be used as any other button:
const SomePage = () => {
return (
<>
<Button />
<ButtonWithSomeLogic />
</>
);
};
If we want to create a mental map of what goes where it could look something like this:
Play around with those examples in codesandbox.
Before the introduction of hooks, higher-order components were widely used for accessing context and any external data subscriptions. Redux connect or react-routerâs withRouter
functions are higher-order components: they accept a component, inject some props into it, and return it back.
// location is injected by the withRouter higher-order component
// would you guessed that by the look at this component alone?
const SomeComponent = ({ location }) => {
return <>{location}</>;
};
const ComponentWithRouter = withRouter(SomeComponent);
As you can see, higher-order components are quite complicated to write and to understand. So when the hooks were introduced, no wonder everyone switched to them.
Now, instead of creating complicated mental maps of which prop goes where and trying to figure out how location
ended up in props, we can just write:
const SomeComponent = () => {
// we see immediately where location is coming from
const { location } = useRouter();
return <>{location}</>;
};
Everything that is happening in the component can be read from top to bottom and the source of all the data is obvious, which significantly simplifies debugging and development.
And while hooks probably replaced 90% of shared logic concerns and 100% of use-cases for accessing context, there are still at least three types of functionality, where higher-order components could be useful.
Letâs take a look at those.
First: enhancing callbacks and React lifecycle events
Imagine you need to send some sort of advanced logging on some callbacks. When you click a button, for example, you want to send some logging events with some data. How would you do it with hooks? Youâd probably have a Button
component with an onClick
callback:
type ButtonProps = {
onClick: () => void;
children: ReactNode;
}
const Button = ({ onClick }: { onClick }: ButtonProps) => {
return <button onClick={onClick}>{children}</button>
}
And then on the consumer side, youâd hook into that callback and send logging event there:
const SomePage = () => {
const log = useLoggingSystem();
const onClick = () => {
log('Button was clicked');
};
return <Button onClick={() => onClick}>Click here</Button>;
};
And that is fine if you want to fire an event or two. But what if you want your logging events to be consistently fired across your entire app, whenever the button is clicked? We probably can bake it into the Button
component itself.
const Button = ({ onClick }: { onClick }: ButtonProps) => {
const log = useLoggingSystem();
const onButtonClick = () => {
log('Button was clicked')
onClick();
}
return <button onClick={() => onClick()}>{children}</button>
}
But then what? For proper logs youâd have to send some sort of data as well. We surely can extend the Button
component with some loggingData
props and pass it down:
const Button = ({ onClick, loggingData }: { onClick, loggingData }: ButtonProps) => {
const onButtonClick = () => {
log('Button was clicked', loggingData)
onClick();
}
return <button onClick={() => onButtonClick()}>{children}</button>
}
But what if you want to fire the same events when the click has happened on other components? Button
is usually not the only thing people can click on in our apps. What if I want to add the same logging to a ListItem
component? Copy-paste exactly the same logic there?
const ListItem = ({ onClick, loggingData }: { onClick, loggingData }: ListItemProps) => {
const onListItemClick = () => {
log('List item was clicked', loggingData)
onClick();
}
return <Item onClick={() => onListItemClick()}>{children}</Item>
}
Too much copy-pasta and prone to errors and someone forgetting to change something in my taste.
What I want, essentially, is to encapsulate the logic of âsomething triggered onClick
callback - send some logging eventsâ somewhere, and then just re-used it in any component I want, without changing the code of those components in any way.
And this is the first use case where the hooks are no use, but higher-order components could come in handy.
Higher-order component to enhance onClick callback
Instead of copy-pasting the âclick happened â log dataâ logic everywhere, I can just create a withLoggingOnClick
function, that:
- accepts a component as an argument
- intercepts its onClick callback
- sends the data that I need to the whatever external framework is used for logging
- returns the component with onClick callback intact for further use
It would look something like this:
type Base = { onClick: () => void };
// just a function that accepts Component as an argument
export const withLoggingOnClick = <TProps extends Base>(Component: ComponentType<TProps>) => {
return (props: TProps) => {
const onClick = () => {
console.log('Log on click something');
// don't forget to call onClick that is coming from props!
// we're overriding it below
props.onClick();
};
// return original component with all the props
// and overriding onClick with our own callback
return <Component {...props} onClick={onClick} />;
};
};
And now I can just add it to any component that I want. I can have a Button
with logging baked in:
export const ButtonWithLoggingOnClick = withLoggingOnClick(SimpleButton);
Or use it in the list item:
export const ListItemWithLoggingOnClick = withLoggingOnClick(ListItem);
Or any other component that has onClick
callback that I want to track. Without a single line of code changed in either Button
or ListItem
components!
Adding data to the higher-order component
Now, whatâs left to do, is to add some data from the outside to the logging function. And considering that higher-order component is nothing more than just a function, we can do that easily. Just need to add some other arguments to the function, thatâs it:
type Base = { onClick: () => void };
export const withLoggingOnClickWithParams = <TProps extends Base>(
Component: ComponentType<TProps>,
// adding some params as a second argument to the function
params: { text: string },
) => {
return (props: TProps) => {
const onClick = () => {
// accessing params that we passed as an argument here
// everything else stays the same
console.log('Log on click: ', params.text);
props.onClick();
};
return <Component {...props} onClick={onClick} />;
};
};
And now, when we wrap our button with higher-order component, we can pass the text that we want to log:
const ButtonWithLoggingOnClickWithParams = withLoggingOnClickWithParams(SimpleButton, { text: 'button component' });
On the consumer side, weâd just use this button as a normal button component, without worrying about the logging text:
const Page = () => {
return <ButtonWithLoggingOnClickWithParams onClick={onClickCallback}>Click me</ButtonWithLoggingOnClickWithParams>;
};
But what if we actually want to worry about this text? What if we want to send different texts in different contexts of where the button is used? We wouldnât want to create one million wrapped buttons for every use case.
Also very easy to solve: instead of passing that text as functionâs argument, we can inject it as a prop to the resulting button. The code would look like this:
type Base = { onClick: () => void };
export const withLoggingOnClickWithProps = <TProps extends Base>(Component: ComponentType<TProps>) => {
// our returned component will now have additional logText prop
return (props: TProps & { logText: string }) => {
const onClick = () => {
// accessing it here, as any other props
console.log('Log on click: ', props.logText);
props.onClick();
};
return <Component {...props} onClick={onClick} />;
};
};
And then use it like this:
const Page = () => {
return (
<ButtonWithLoggingOnClickWithProps onClick={onClickCallback} logText="this is Page button">
Click me
</ButtonWithLoggingOnClickWithProps>
);
};
See the codesandbox with all the examples.
Sending data on mount instead of click
We are not limited to clicks and callbacks here. Remember, those are just components, we can do whatever we want and need đ We can use everything React has to offer. For example, we can send those logging events when a component is mounted:
export const withLoggingOnMount = <TProps extends unknown>(Component: ComponentType<TProps>) => {
return (props: TProps) => {
// no more overriding onClick, just adding normal useEffect
useEffect(() => {
console.log('log on mount');
}, []);
// just passing props intact
return <Component {...props} />;
};
};
And exactly the same story as with onClick
for adding data via arguments or props. Not going to copy-paste it here, see it in the codesandbox.
We can even go wild and combine all of those higher-order components:
export const SuperButton = withLoggingOnClick(
withLoggingOnClickWithParams(
withLoggingOnClickWithProps(
withLoggingOnMount(withLoggingOnMountWithParams(withLoggingOnMountWithProps(SimpleButton), { text: 'button component' })),
),
{ text: 'button component' },
),
);
We shouldnât do this of course though đ
If something is possible, it doesnât always mean itâs a good idea. Imagine trying to trace which props come from where, when debugging time comes. If we really need to combine a few higher-order components into one, we can be at least a bit more specific about it:
const ButtonWithLoggingOnClick = withLoggingOnClick(SimpleButton);
const ButtonWithLoggingOnClickAndMount = withLoggingOnMount(ButtonWithLoggingOnClick);
// etc
Second: intercepting DOM events
Another very useful application of higher-order components is intercepting various DOM events. Imagine, for example, you implement some sort of keyboard shortcuts functionality on your page. When specific keys are pressed, you want to do various things, like open dialogs, creating issues, etc. Youâd probably add an event listener to window for something like this:
useEffect(() => {
const keyPressListener = (event) => {
// do stuff
};
window.addEventListener('keypress', keyPressListener);
return () => window.removeEventListener('keypress', keyPressListener);
}, []);
And then, you have various parts of your app, like modal dialogs, dropdown menus, drawers, etc, where you want to block that global listener while the dialog is open. If it was just one dialog, you can manually add onKeyPress
to the dialog itself and there do event.stopPropagation()
for that:
export const Modal = ({ onClose }: ModalProps) => {
const onKeyPress = (event) => event.stopPropagation();
return <div onKeyPress={onKeyPress}>...// dialog code</div>;
};
But the same story as with onClick
logging - what if you have multiple components where you want to see this logic?
What we can do here, is again implement a higher-order component. This time it will accept a component, wrap it in a div with onKeyPress callback attached, and return the component unchanged.
export const withSupressKeyPress = <TProps extends unknown>(Component: ComponentType<TProps>) => {
return (props: TProps) => {
const onKeyPress = (event) => {
event.stopPropagation();
};
return (
<div onKeyPress={onKeyPress}>
<Component {...props} />
</div>
);
};
};
That is it! Now we can just use it everywhere:
const ModalWithSupressedKeyPress = withSupressKeyPress(Modal);
const DropdownWithSupressedKeyPress = withSupressKeyPress(Dropdown);
// etc
One Important thing to note here: focus management. In order for the above code to actually work, you need to make sure that your dialog-type components move focus to the opened part when they are open. But this is a completely different conversation on focus management, maybe next time.
For the purpose of the example, we can just manually include auto-focus in the modal itself:
const Modal = () => {
const ref = useRef<HTMLDivElement>();
useEffect(() => {
// when modal is mounted, focus the element to which the ref is attached
if (ref.current) ref.current.focus();
}, []);
// adding tabIndex and ref to the div, so now it's focusable
return <div tabIndex={1} ref={ref}>
<!-- modal code -->
</div>
}
Play around with it in the codesandbox.
Third: context selectors
The final and very interesting use case for higher-order components: selectors-like functionality for React context. As we know, when context value changes, it will cause re-renders of all context consumers, regardless of whether their particular part of the state was changed or not. (And if you didnât know about it, hereâs the article for you: How to write performant React apps with Context).
Letâs implement some context and form first, before jumping into higher-order components.
Weâll have Context with id
and name
and API to change those:
type Context = {
id: string;
name: string;
setId: (val: string) => void;
setName: (val: string) => void;
};
const defaultValue = {
id: 'FormId',
name: '',
setId: () => undefined,
setName: () => undefined,
};
const FormContext = createContext<Context>(defaultValue);
export const useFormContext = () => useContext(FormContext);
export const FormProvider = ({ children }: { children: ReactNode }) => {
const [state, setState] = useState(defaultValue);
const value = useMemo(() => {
return {
id: state.id,
name: state.name,
setId: (id: string) => setState({ ...state, id }),
setName: (name: string) => setState({ ...state, name }),
};
}, [state]);
return <FormContext.Provider value={value}>{children}</FormContext.Provider>;
};
And then some form with Name
and Countries
components
const Form = () => {
return (
<form css={pageCss}>
<Name />
<Countries />
</form>
);
};
export const Page = () => {
return (
<FormProvider>
<Form />
</FormProvider>
);
};
Where in Name
component weâll have an input that changes the value of Context
, and Countries
just use the id
of the form to fetch the list of countries (not going to implement the actual fetch, not important for the example:
const Countries = () => {
// using only id from context here
const { id } = useFormContext();
console.log("Countries re-render");
return (
<div>
<h3>List on countries for form: {id}</h3>
<ul>
<li>Australia</li>
<li>USA</li>
<!-- etc -->
</ul>
</div>
);
};
const Name = () => {
// using name and changing it here
const { name, setName } = useFormContext();
return <input onChange={(event) => setName(event.target.value)} value={name} />;
};
Now, every time we type something in the name input field, weâll update the context value, which will cause re-render of all components that use context, including Countries. And this canât be solved by extracting this value into a hook and memoising it: hooks always re-render (Why custom react hooks could destroy your app performance).
There are other ways to deal with it of course, if this behaviour causes performance concerns, like memoising parts of render tree or splitting Context into different providers (see those articles that describe those techniques: How to write performant React apps with Context and How to write performant React code: rules, patterns, do's and don'ts).
But big disadvantage of all the techniques above, is that they are not shareable and need to be implemented on a case-by-case basis. Wouldnât it be nice, if we had some select-like functionality, that we can use to extract this id
value safely in any component, without significant refactorings and useMemo
all over the app?
Interestingly enough, we can implement something like this with higher-order components. And the reason for this is that components have one thing that hooks donât give us: they can memoise things and stop the chain of re-renders going down to children. Basically, this will give us what we want:
export const withFormIdSelector = <TProps extends unknown>(
Component: ComponentType<TProps & { formId: string }>
) => {
const MemoisedComponent = React.memo(Component) as ComponentType<
TProps & { formId: string }
>;
return (props: TProps) => {
const { id } = useFormContext();
return <MemoisedComponent {...props} formId={id} />;
};
};
and then we can just create CountriesWithFormIdSelector
component:
// formId prop here is injected by the higher-order component below
const CountriesWithFormId = ({ formId }: { formId: string }) => {
console.log("Countries with selector re-render");
return (
<-- code is the same as before -->
);
};
const CountriesWithFormIdSelector = withFormIdSelector(CountriesWithFormId);
And use it in our form:
const Form = () => {
return (
<form css={pageCss}>
<Name />
<CountriesWithFormIdSelector />
</form>
);
};
Check it out in the codesandbox. Pay special attention of the
console output when typing in the input - CountriesWithFormIdSelector component doesnât re-render!
Generic React context selector
withFormIdSelector
is fun and could work for small context-based apps. But wouldnât it be nice to have it as something generic? So that we donât have to implement a custom selector for every state property.
No problem when some creative hackery is involved! Check it out, selector itself:
export const withContextSelector = <TProps extends unknown, TValue extends unknown>(
Component: ComponentType<TProps & Record<string, TValue>>,
selectors: Record<string, (data: Context) => TValue>,
): ComponentType<Record<string, TValue>> => {
// memoising component generally for every prop
const MemoisedComponent = React.memo(Component) as ComponentType<Record<string, TValue>>;
return (props: TProps & Record<string, TValue>) => {
// extracting everything from context
const data = useFormContext();
// mapping keys that are coming from "selectors" argument
// to data from context
const contextProps = Object.keys(selectors).reduce((acc, key) => {
acc[key] = selectors[key](data);
return acc;
}, {});
// spreading all props to the memoised component
return <MemoisedComponent {...props} {...contextProps} />;
};
};
and then use it with components:
// props are injected by the higher order component below
const CountriesWithFormId = ({ formId, countryName }: { formId: string; countryName: string }) => {
console.log('Countries with selector re-render');
return (
<div>
<h3>List of countries for form: {formId}</h3>
Selected country: {countryName}
<ul>
<li>Australia</li>
<li>USA</li>
</ul>
</div>
);
};
// mapping props to selector functions
const CountriesWithFormIdSelector = withContextSelector(CountriesWithFormId, {
formId: (data) => data.id,
countryName: (data) => data.country,
});
And thatâs it! we basically implemented mini-Redux on context, even with proper mapStateToProps
functionality đ Check it out in the codesandbox.
That is it for today! Hope higher-order components are not some terrifying legacy goblins now, but something you can put to good use even in modern apps. Letâs re-cap the use cases for those:
- to enhance callbacks and React lifecycle events with additional functionality, like sending logging or analytics events
- to intercept DOM events, like blocking global keyboard shortcuts when a modal dialog is open
- to extract a piece of Context without causing unnecessary re-renders in the component
May the peace and love be with you âđź
...
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.