Developer way

Higher-Order Components in React Hooks era

Nadia Makarevich

Nadia Makarevich

Feb 27, 2022

Nadia MakarevichNadia Makarevich

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:

1// accept a Component as an argument
2const withSomeLogic = (Component) => {
3 // do something
4
5 // return a component that renders the component from the argument
6 return (props) => <Component {...props} />;
7};

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:

1const Button = ({ onClick }) => <button onClick={func}>Button</button>;
2const 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:

1const SomePage = () => {
2 return (
3 <>
4 <Button />
5 <ButtonWithSomeLogic />
6 </>
7 );
8};

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.

1// location is injected by the withRouter higher-order component
2// would you guessed that by the look at this component alone?
3const SomeComponent = ({ location }) => {
4 return <>{location}</>;
5};
6
7const 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:

1const SomeComponent = () => {
2 // we see immediately where location is coming from
3 const { location } = useRouter();
4
5 return <>{location}</>;
6};

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:

1type ButtonProps = {
2 onClick: () => void;
3 children: ReactNode;
4}
5
6const Button = ({ onClick }: { onClick }: ButtonProps) => {
7 return <button onClick={onClick}>{children}</button>
8}

And then on the consumer side, you’d hook into that callback and send logging event there:

1const SomePage = () => {
2 const log = useLoggingSystem();
3
4 const onClick = () => {
5 log('Button was clicked');
6 };
7
8 return <Button onClick={() => onClick}>Click here</Button>;
9};

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.

1const Button = ({ onClick }: { onClick }: ButtonProps) => {
2 const log = useLoggingSystem();
3
4 const onButtonClick = () => {
5 log('Button was clicked')
6 onClick();
7 }
8
9 return <button onClick={() => onClick()}>{children}</button>
10}

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:

1const Button = ({ onClick, loggingData }: { onClick, loggingData }: ButtonProps) => {
2 const onButtonClick = () => {
3 log('Button was clicked', loggingData)
4 onClick();
5 }
6 return <button onClick={() => onButtonClick()}>{children}</button>
7}

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?

1const ListItem = ({ onClick, loggingData }: { onClick, loggingData }: ListItemProps) => {
2 const onListItemClick = () => {
3 log('List item was clicked', loggingData)
4 onClick();
5 }
6 return <Item onClick={() => onListItemClick()}>{children}</Item>
7}

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:

1type Base = { onClick: () => void };
2
3// just a function that accepts Component as an argument
4export const withLoggingOnClick = <TProps extends Base>(Component: ComponentType<TProps>) => {
5 return (props: TProps) => {
6 const onClick = () => {
7 console.log('Log on click something');
8 // don't forget to call onClick that is coming from props!
9 // we're overriding it below
10 props.onClick();
11 };
12
13 // return original component with all the props
14 // and overriding onClick with our own callback
15 return <Component {...props} onClick={onClick} />;
16 };
17};

And now I can just add it to any component that I want. I can have a Button with logging baked in:

1export const ButtonWithLoggingOnClick = withLoggingOnClick(SimpleButton);

Or use it in the list item:

1export 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:

1type Base = { onClick: () => void };
2export const withLoggingOnClickWithParams = <TProps extends Base>(
3 Component: ComponentType<TProps>,
4 // adding some params as a second argument to the function
5 params: { text: string },
6) => {
7 return (props: TProps) => {
8 const onClick = () => {
9 // accessing params that we passed as an argument here
10 // everything else stays the same
11 console.log('Log on click: ', params.text);
12 props.onClick();
13 };
14
15 return <Component {...props} onClick={onClick} />;
16 };
17};

And now, when we wrap our button with higher-order component, we can pass the text that we want to log:

1const 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:

1const Page = () => {
2 return <ButtonWithLoggingOnClickWithParams onClick={onClickCallback}>Click me</ButtonWithLoggingOnClickWithParams>;
3};

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:

1type Base = { onClick: () => void };
2export const withLoggingOnClickWithProps = <TProps extends Base>(Component: ComponentType<TProps>) => {
3 // our returned component will now have additional logText prop
4 return (props: TProps & { logText: string }) => {
5 const onClick = () => {
6 // accessing it here, as any other props
7 console.log('Log on click: ', props.logText);
8 props.onClick();
9 };
10
11 return <Component {...props} onClick={onClick} />;
12 };
13};

And then use it like this:

1const Page = () => {
2 return (
3 <ButtonWithLoggingOnClickWithProps onClick={onClickCallback} logText="this is Page button">
4 Click me
5 </ButtonWithLoggingOnClickWithProps>
6 );
7};

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:

1export const withLoggingOnMount = <TProps extends unknown>(Component: ComponentType<TProps>) => {
2 return (props: TProps) => {
3 // no more overriding onClick, just adding normal useEffect
4 useEffect(() => {
5 console.log('log on mount');
6 }, []);
7
8 // just passing props intact
9 return <Component {...props} />;
10 };
11};

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:

1export const SuperButton = withLoggingOnClick(
2 withLoggingOnClickWithParams(
3 withLoggingOnClickWithProps(
4 withLoggingOnMount(withLoggingOnMountWithParams(withLoggingOnMountWithProps(SimpleButton), { text: 'button component' })),
5 ),
6 { text: 'button component' },
7 ),
8);

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:

1const ButtonWithLoggingOnClick = withLoggingOnClick(SimpleButton);
2const ButtonWithLoggingOnClickAndMount = withLoggingOnMount(ButtonWithLoggingOnClick);
3// 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:

1useEffect(() => {
2 const keyPressListener = (event) => {
3 // do stuff
4 };
5
6 window.addEventListener('keypress', keyPressListener);
7
8 return () => window.removeEventListener('keypress', keyPressListener);
9}, []);

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:

1export const Modal = ({ onClose }: ModalProps) => {
2 const onKeyPress = (event) => event.stopPropagation();
3
4 return <div onKeyPress={onKeyPress}>...// dialog code</div>;
5};

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.

1export const withSupressKeyPress = <TProps extends unknown>(Component: ComponentType<TProps>) => {
2 return (props: TProps) => {
3 const onKeyPress = (event) => {
4 event.stopPropagation();
5 };
6
7 return (
8 <div onKeyPress={onKeyPress}>
9 <Component {...props} />
10 </div>
11 );
12 };
13};

That is it! Now we can just use it everywhere:

1const ModalWithSupressedKeyPress = withSupressKeyPress(Modal);
2const DropdownWithSupressedKeyPress = withSupressKeyPress(Dropdown);
3// 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:

1const Modal = () => {
2 const ref = useRef<HTMLDivElement>();
3
4 useEffect(() => {
5 // when modal is mounted, focus the element to which the ref is attached
6 if (ref.current) ref.current.focus();
7 }, []);
8
9 // adding tabIndex and ref to the div, so now it's focusable
10 return <div tabIndex={1} ref={ref}>
11 <!-- modal code -->
12 </div>
13}

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:

1type Context = {
2 id: string;
3 name: string;
4 setId: (val: string) => void;
5 setName: (val: string) => void;
6};
7
8const defaultValue = {
9 id: 'FormId',
10 name: '',
11 setId: () => undefined,
12 setName: () => undefined,
13};
14
15const FormContext = createContext<Context>(defaultValue);
16
17export const useFormContext = () => useContext(FormContext);
18
19export const FormProvider = ({ children }: { children: ReactNode }) => {
20 const [state, setState] = useState(defaultValue);
21
22 const value = useMemo(() => {
23 return {
24 id: state.id,
25 name: state.name,
26 setId: (id: string) => setState({ ...state, id }),
27 setName: (name: string) => setState({ ...state, name }),
28 };
29 }, [state]);
30
31 return <FormContext.Provider value={value}>{children}</FormContext.Provider>;
32};

And then some form with Name and Countries components

1const Form = () => {
2 return (
3 <form css={pageCss}>
4 <Name />
5 <Countries />
6 </form>
7 );
8};
9
10export const Page = () => {
11 return (
12 <FormProvider>
13 <Form />
14 </FormProvider>
15 );
16};

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:

1const Countries = () => {
2 // using only id from context here
3 const { id } = useFormContext();
4
5 console.log("Countries re-render");
6 return (
7 <div>
8 <h3>List on countries for form: {id}</h3>
9 <ul>
10 <li>Australia</li>
11 <li>USA</li>
12 <!-- etc -->
13 </ul>
14 </div>
15 );
16};
1const Name = () => {
2 // using name and changing it here
3 const { name, setName } = useFormContext();
4
5 return <input onChange={(event) => setName(event.target.value)} value={name} />;
6};

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:

1export const withFormIdSelector = <TProps extends unknown>(
2 Component: ComponentType<TProps & { formId: string }>
3) => {
4 const MemoisedComponent = React.memo(Component) as ComponentType<
5 TProps & { formId: string }
6 >;
7
8 return (props: TProps) => {
9 const { id } = useFormContext();
10
11 return <MemoisedComponent {...props} formId={id} />;
12 };
13};

and then we can just create CountriesWithFormIdSelector component:

1// formId prop here is injected by the higher-order component below
2const CountriesWithFormId = ({ formId }: { formId: string }) => {
3 console.log("Countries with selector re-render");
4 return (
5 <-- code is the same as before -->
6 );
7};
8
9const CountriesWithFormIdSelector = withFormIdSelector(CountriesWithFormId);

And use it in our form:

1const Form = () => {
2 return (
3 <form css={pageCss}>
4 <Name />
5 <CountriesWithFormIdSelector />
6 </form>
7 );
8};
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:

1export const withContextSelector = <TProps extends unknown, TValue extends unknown>(
2 Component: ComponentType<TProps & Record<string, TValue>>,
3 selectors: Record<string, (data: Context) => TValue>,
4): ComponentType<Record<string, TValue>> => {
5 // memoising component generally for every prop
6 const MemoisedComponent = React.memo(Component) as ComponentType<Record<string, TValue>>;
7
8 return (props: TProps & Record<string, TValue>) => {
9 // extracting everything from context
10 const data = useFormContext();
11
12 // mapping keys that are coming from "selectors" argument
13 // to data from context
14 const contextProps = Object.keys(selectors).reduce((acc, key) => {
15 acc[key] = selectors[key](data);
16
17 return acc;
18 }, {});
19
20 // spreading all props to the memoised component
21 return <MemoisedComponent {...props} {...contextProps} />;
22 };
23};

and then use it with components:

1// props are injected by the higher order component below
2const CountriesWithFormId = ({ formId, countryName }: { formId: string; countryName: string }) => {
3 console.log('Countries with selector re-render');
4 return (
5 <div>
6 <h3>List of countries for form: {formId}</h3>
7 Selected country: {countryName}
8 <ul>
9 <li>Australia</li>
10 <li>USA</li>
11 </ul>
12 </div>
13 );
14};
15
16// mapping props to selector functions
17const CountriesWithFormIdSelector = withContextSelector(CountriesWithFormId, {
18 formId: (data) => data.id,
19 countryName: (data) => data.country,
20});

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 ✌🏼

Share on:Share "Higher-Order Components in React Hooks era" on TwitterShare "Higher-Order Components in React Hooks era" on LinkedInShare "Higher-Order Components in React Hooks era" on Reddit
Get the latest content by email
    No spam or ads. Unsubscribe at any time.
    © Developer Way 2021