Higher-Order Components in React Hooks era

Nadia Makarevich - Feb 27 '22 - - Dev Community

Image description

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

If we want to create a mental map of what goes where it could look something like this:

Image description

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);
Enter fullscreen mode Exit fullscreen mode

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}</>;
};
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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} />;
  };
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Or use it in the list item:

export const ListItemWithLoggingOnClick = withLoggingOnClick(ListItem);
Enter fullscreen mode Exit fullscreen mode

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} />;
  };
};
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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} />;
  };
};
Enter fullscreen mode Exit fullscreen mode

And then use it like this:

const Page = () => {
  return (
    <ButtonWithLoggingOnClickWithProps onClick={onClickCallback} logText="this is Page button">
      Click me
    </ButtonWithLoggingOnClickWithProps>
  );
};
Enter fullscreen mode Exit fullscreen mode

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} />;
  };
};
Enter fullscreen mode Exit fullscreen mode

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' },
  ),
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}, []);
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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>
    );
  };
};
Enter fullscreen mode Exit fullscreen mode

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

const ModalWithSupressedKeyPress = withSupressKeyPress(Modal);
const DropdownWithSupressedKeyPress = withSupressKeyPress(Dropdown);
// etc
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode
const Name = () => {
  // using name and changing it here
  const { name, setName } = useFormContext();

  return <input onChange={(event) => setName(event.target.value)} value={name} />;
};
Enter fullscreen mode Exit fullscreen mode

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} />;
  };
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

And use it in our form:

const Form = () => {
  return (
    <form css={pageCss}>
      <Name />
      <CountriesWithFormIdSelector />
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

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} />;
  };
};
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .