React higher-order components with TypeScript

Table of contents

Open Table of contents

What is a higher-order component?

From the React docs:

A higher-order component (HOC) is an advanced technique in React for reusing component logic. HOCs are not part of the React API, per se. They are a pattern that emerges from React’s compositional nature.

Concretely, a higher-order component is a function that takes a component and returns a new component.

The purpose of HOCs is often to reuse common logic between components and separate logical and presentational layers. With the introduction of hooks, HOCs took a back seat for the most part. Although you see them less, HOCs are still relevant and have certain advantages over hooks, for example, better compatibility with class-based components.

const EnhancedComponent = withExtraFunctionality(WrappedComponent)

In this example, withExtraFunctionality is the higher-order component and its goal is to add some functionality to WrappedComponent.

Well-known examples of HOCs are the withFormik() function from the formik library or the connect function from React-Redux.

Creating a HOC

Setting up the basic functionalities

Let’s start by scaffolding our HOC first. We will create a simple HOC that adds timer functionality to our components. Our first iteration will not have strict typing, but we will tighten it up as we progress.

Here’s what the first version of our higher-order component looks like:

import { useCallback, useState } from 'react'

export function withTimer(Component: any) {
  return () => {
    const [count, setCount] = useState(-1)
    const [timer, setTimer] = useState(-1)

    const startTimer = useCallback(() => {
      const timer = setInterval(() => {
        setCount((previous) => {
          return previous + 1
        }),
          1000
      })
    }, [])

    const endTimer = useCallback(() => {
      clearInterval(timer)
      setCount(0)
    }, [timer])

    return (
      <Component
        startTimer={startTimer}
        endTimer={endTimer}
        count={count}
      />
    )
  }
}

At this point, our HOC is not very type-safe since we set any for the target component’s type. There’s no type checking to make sure the component we pass accepts the props we’re trying to inject.

Now let’s start tightening the type checking.

Adding Generics

Let’s make it so that our HOC only accepts React components and that it expects the same props as the target component. We can use TypeScript generics to enforce these restraints:

import { ComponentType, useCallback, useState } from 'react'

export function withTimer<T>(Component: ComponentType<T>) {
  return (hocProps: T) => {
    const [count, setCount] = useState(-1)
    const [timer, setTimer] = useState(-1)

    const startTimer = useCallback(() => {
      const timer = setInterval(() => {
        setCount((previous) => {
          return previous + 1
        }),
          1000
      })
      setTimer(timer)
    }, [])

    const endTimer = useCallback(() => {
      clearInterval(timer)
      setCount(0)
    }, [timer])

    return (
      <Component
        {...hocProps}
        startTimer={startTimer}
        endTimer={endTimer}
        count={count}
      />
    )
  }
}

ComponentType is a special type React provides for working with components in TypeScript.

Also, notice our use of generics. We used the T type in several places:

  • We’re setting our parameter type to ComponentType<T>. Now, within the scope of this function, T denotes the props type of the target component.
  • We’re also setting the hocProps type to T to enforce that our HOC component receives the same props as the target.

Thanks to generics, TypeScript can dynamically calculate all of the props our original component accepts and enforce the same restriction for the HOC. You can learn more about generics from TypeScript docs.

Using it to enhance a component

Now let’s say we have a component like this:

import { withTimer } from './withTimer'

interface ComponentProps {
  name: string;
}

function BasicComponent(props: ComponentProps) {
  return (
    <div>
      <p>{props.name}</p>
      <p>{props.count}</p>
    </div>
  )
}

const BasicWithTimer = withTimer(BasicComponent)

export default BasicWithTimer

Notice that we are not exporting the component, but the wrapped component returned by withTimer.

At this point, we have a component enhanced by the HOC, but there’s a problem.: although the HOC is adding startTimer, endTimer and count, Typescript will show an error when we try to use props.count (or any other prop injected by the HOC) because it only expects ComponentProps.

The best way we have to solve this is to create a type for the props that withTimer will be injecting, export that type and then extend it with ComponentProps.

So now our HOC looks like this:

import { ComponentType, useCallback, useState } from 'react'

export interface WithTimerProps {
  startTimer: VoidFunction
  endTimer: VoidFunction
  count: number
}

export function withTimer<T extends WithTimerProps>(Component: ComponentType<T>) {
  return (hocProps: Omit<T, 'startTimer' | 'endTimer' | 'count'>) => {
    const [count, setCount] = useState(-1)
    const [timer, setTimer] = useState(-1)

    const startTimer = useCallback(() => {
      const timer = setInterval(() => {
        setCount((previous) => {
          return previous + 1
        }),
          1000
      })
      setTimer(timer)
    }, [])

    const endTimer = useCallback(() => {
      clearInterval(timer)
      setCount(0)
    }, [timer])

    return (
      <Component
        {...(hocProps as T)}
        startTimer={startTimer}
        endTimer={endTimer}
        count={count}
      />
    )
  }
}

A couple of things are worth noticing:

  1. We created and exported WithTimerProps interface.
  2. We changed the HOC type parameter from T to T extends WithTimerProps. This change ensures that TypeScript will display an error if we try to pass a component to withTimer whose props do not extend WithTimerProps.
  3. Using Omit utility type on hocProps typing, we created a new type that expects all of the same props as T except for count, startTimer, and endTimer.
  4. Since Omit creates a new type, we had to use a workaround {...(hocProps as T)} to let TypeScript know that we expect the hocProps to be almost identical to T except for the props we omitted.

And our component looks like this:

import { WithTimerProps, withTimer } from './withTimer'

interface ComponentProps extends WithTimerProps {
  name: string;
}

function BasicComponent(props: ComponentProps) {
  return (
    <div>
      <p>{props.name}</p>
      <p>{props.count}</p>
    </div>
  )
}

const BasicWithTimer = withTimer(BasicComponent)

export default BasicWithTimer

A better (more complex) example

In this link there is an example project that uses a HOC, a custom hook that fetches information from an external API and has a couple of different routes.

In the README of the repository, it is explained in detail how both the higher-order component and custom hooks work. Make sure to clone the repo and make changes, as it is the best way to solidify the information from this post.