emeraldwalk

Strong Typed React Router

I love to build apps using TypeScript and React. I've tried React Router on a few occasions, but I've usually had trouble figuring out how to tie my route matching paths to component props in a strong typed way that I felt good about. I think I finally found a configuration that I like which is the topic of this post.

The Setup

I am currently working on a scheduling app. At the moment it's pretty simple and has only 2 routes.

  • '/' - routes to a ScheduleList component
  • '/schedule:id' - routes to a Schedule component

Each of my routes maps to a top level component. Their props look like:

interface ScheduleListProps {
}
interface ScheduleProps {
  id: string
}

I then have a TypeScript interface that defines the mapping of route matching paths to component props. Since the keys are treated as string literals, this mapping is strongly typed:

/** Map route to component props type */
interface RouteParams {
  '/': {}
  '/schedule/:id': { id: string }
}

The top level router of my app looks something like:

<Router>
  <PrimaryNav />
  <CustomRoute
    path="/"
    exact={true}
    component={ScheduleList}
    />
  <CustomRoute
    path="/schedule/:id"
    component={Schedule}
    />
</Router>

Notice that I am using a CustomRoute component. The Route component that comes with react-router-dom passes a nested object as props to the component designated by the component prop, so I wrote a custom component more tailored to my use case.

Custom Route Component

My CustomRoute component does 2 primary things

  1. Enforces the relationship of path matching patterns to component props
  2. Passes any parameters extracted from the route as props to the corresponding component

To pull this off I created a few helper types.

/** This is just a union type of my route matching strings */
type RoutePath = keyof RouteParams

/** Helper type to derive route props from path */
type Params<TPath extends RoutePath> = TPath extends RoutePath
  ? RouteParams[TPath]
  : never
  • RoutePath - union type of all of my route matching paths
  • Params - helper type to infer prop types from given matching path

Now for the custom route component.

import React from 'react'
import * as ReactRouter from 'react-router-dom'

...

/** Override RouteProps with generics */
interface CustomRouteProps<TPath extends RoutePath>
  extends Omit<ReactRouter.RouteProps, 'component' | 'path'> {

  // tie our component type to our path type
  component: React.ComponentType<Params<TPath>>
  path: TPath
}

/**
 * Route wrapper component that extracts route params
 * and passes them to the given component prop.
 */
function CustomRoute<TPath extends RoutePath>({
  component: Component,
  ...rest
}: CustomRouteProps<TPath>) {
  return (
    <ReactRouter.Route
      {...rest}
      render={({ match: { params } }) => <Component {...params} />}
    />
  )
}

The code here is a little dense, so I'll try to unpack it a bit.

CustomRouteProps extends the RouteProps that come with @types/react-router-dom. It does so by omitting the component and path props and replacing them with ones tied to the generic TPath arg. This is where the path types actually get tied to the component prop types.

The CustomRoute component is just a wrapper around the Route component provided by react router. It uses CustomRouteProps to map paths to prop types and also spreads the match params to the component so that it only gets the props I care about.

The Result

The result is that if I pass an untyped path to a Route component, the TypeScript compiler will complain.

<CustomRoute
  path="/invalid"
  component={Schedule}
  />

The compiler will also complain if I pass a component whose props don't map to the given path. For example my Schedule component takes a single id prop.

export interface ScheduleProps {
  id: string
}

const Schedule: React.FC<ScheduleProps> = ({ id }) => {
  return <div>...</div>
}

If I pass it to my home route, the compiler will complain, since the path provides no args, and my component expects an id.

<CustomRoute
  path="/"
  component={Schedule}
  exact={true}
  />

Conclusion

I can now use the TypeScript compiler to enforce my route mappings. This gives me extra protection as I add more routes, change route patterns or component props. Hope this is helpful to others as well. Peace.