Higher-Order Components with React & TypeScript

Higher-order components are a great way of reusing common functionality in your react app.

TypeScript is a great way of making sure your react app can grow without you losing your mind.

So how can we use the two together?

Higher-Order Components

A higher-order component (HoC) is a function that accepts a component as an argument and returns a new component that has been enhanced in some way.

const EnhancedComponent = myHigherOrderComponent(WrappedComponent);

The enhanced component can add behaviour, manage state or in any other way modify WrappedComponent.

Injecting Props

A common pattern is for a higher-order component to provide some props to the wrapped component, moving the management of those props out of WrappedComponent and allowing it to focus on other concerns.

For example, connect(...)(WrappedComponent) in react-redux provides props to both expose redux state and to dispatch actions. withRouter(WrappedComponent) in react-router similarly injects routing-related props into WrappedComponent from the current route.

Getting the prop types right when injecting props can be a bit fiddly. We want:

  1. WrappedComponent to have new props injected automatically
  2. Injected props not to be required on the created EnhancedComponent
  3. Any other props on WrappedComponent to be forwarded from EnhancedComponent
  4. TypeScript to infer all of the above with no manual specification for the user

For example, let’s say we have a HoC that will manage the isExpanded state of an expander component. It will provide the following props to WrappedComponent

interface ExpanderProps {
  isExpanded: boolean;
  toggleExpanded(): void;
}

Our wrapped component will need to accept these props but it might define some others as well. Let’s say it has a title that needs to be specified and an optional className:

interface WrappedComponentProps {
  isExpanded: boolean;
  toggleExpanded(): void;
  title: string;
  className?: string;
}

note that you could (and should!) define this as WrappedComponentProps extends ExpanderProps... and avoid duplicating the prop definitions. I’ve left this as it is here for clarity

In this case we want both title and className to be available on EnhancedComponent:

const EnhancedComponent = withExpander(WrappedComponent);

const usage = (
  <EnhancedComponent title="The title" className="class-name">
    ...
  </EnhancedComponent>
);

The Wrapped Component

At this stage we have already defined the provided props as an interface and we have an example of what the wrapped component might look like. Let’s flesh that out a bit (and give it a more appropriate name):

interface ExpanderComponentProps extends ExpanderProps {
  title: string;
}

class ExpanderComponent extends PureComponent<ExpanderComponentProps> {
  render() {
    const { isExpanded, toggleExpanded, title, children } = this.props;

    return (
      <>
        <button onClick={toggleExpanded}>{title}</button>
        {isExpanded && children}
      </>
    );
  }
}

Fairly simple: render a button containing title that toggles the expanded state. If isExpanded=true then also render the children.

Create the Enhancer

Now we want to create the enhancer function that we can use to inject isExpanded and toggleExpanded. Our first requirement is that it accepts an argument of WrappedComponent where WrappedComponent accepts the props we want to inject. That function signature might look something like this:

function withExpander(WrappedComponent: ComponentType<ExpanderProps>) {
  //...
}

ComponentType is a react type that allows you to pass in either a component class or a stateless functional component that has props of T

The next requirement is that it returns a component that doesn’t require the ExpanderProps to be specified, which means that we need to provide them from the enhanced component. In this case we’re going to get those values from the state on the component but that’s not a requirement - they can come from anywhere.

Once we have the values we need we can pass them to WrappedComponent in the render method:

function withExpander(WrappedComponent: ComponentType<ExpanderProps>) {
  return class WithExpander extends PureComponent<{}, { isExpanded: boolean }> {
    render() {
      return (
        <WrappedComponent
          {...this.state}
          toggleExpanded={this.toggleExpanded}
        />
      );
    }

    private toggleExpanded = () =>
      this.setState(state => ({ isExpanded: !state.isExpanded }));
  };
}

All right, nice and easy! Except… build failure!

const EnhancedComponent = withExpander(ExpanderComponent);
// Error!

Our ExpanderComponent requires a title prop; when we use it (as WrappedComponent) in withExpander we don’t specify the title!

We could make withExpander aware of the title prop but that’s not very useful for the future: there will likely be other props that need to be passed through and we don’t want to keep adding them to our HoC.

This brings us on to Requirement 3 above: any other props from the wrapped component should be forwarded from the enhanced component. In this case, we really want to have the title prop be exposed on EnhancedComponent.

The first step in getting to that point is to allow the WrappedComponent parameter of our HoC to accept props that aren’t on ExpanderProps. In fact, we need our wrapped component to accept props of both ExpanderProps and whatever else it wants.

We can introduce a generic type parameter to the HoC function to represent that combined props object:

function withExpander<TWrappedComponentProps extends ExpanderProps>(
  WrappedComponent: ComponentType<TWrappedComponentProps>
) {
  //...
}

That solves our build failure: withExpander will now accept any component as long as it has the two isExpanded and toggleExpanded props.

Sadly we’re not quite done yet though; try to use the enhanced component and you will see our next problem:

const EnhancedComponent = withExpander(ExpanderComponent);

const usage = <EnhancedComponent title="Some title" />;
// Error!

Another build failure, this time telling us that title does not exist on the enhanced component. We’ve managed to write our HoC so that it allows extra props but all it does is ignore them!

What we really want here is to create a new Props type that we can assign to our created component. TypeScript 2.8 includes 2 built-in types that are going to help us out:

  • Pick that takes the props from T specified in U. e.g. Pick creates a type with only the isExpanded property
  • Exclude that takes everything from T except U. e.g. Exclude creates a type 'one' | 'three'

By combining these with keyof we can build up the “all props that are on the inner component but excluding the ones we’re providing” type. We might want a better name though…

function withExpander<TWrappedComponentProps extends ExpanderProps>(WrappedComponent: ComponentType<TWrappedComponentProps>) {
  type WrappedComponentPropsExceptProvided = Exclude<keyof TWrappedComponentProps, keyof ExpanderProps>;
  // => "title" | "className"
  type ForwardedProps = Pick<TWrappedComponentProps, WrappedComponentPropsExceptProvided>;
  // => { title: string; className?: string }

  //...
}

Now that we know what our forwarded props should look like we can specify those on the component.

return class WithExpander extends PureComponent<
  ForwardedProps,
  { isExpanded: boolean }
> {
  render() {
    return (
      <WrappedComponent
        {...this.props as any}
        {...this.state}
        toggleExpanded={this.toggleExpanded}
      />
    );
  }

  //...
};

The additional spread of this.props onto WrappedComponent copies the externally-specified props onto the wrapped component. Since typescript 3.2 it is (sadly) necessary to cast to any to avoid this bug - thanks to ramondeklein for pointing this out!

Now we can use our enhanced component as intended.

const EnhancedComponent = withExpander(ExpanderComponent);

const usage = (
  <EnhancedComponent title="title">
    <div>...</div>
  </EnhancedComponent>
);

As a bonus, TypeScript gives us Requirement 4 for free! Type inference means that we don’t have to specify any types explicitly in our usage of this HoC but we still have full type safety. Try removing title or adding an unknown property and your IDE will light up with compilation errors - thanks TypeScript! ✨