Home Products Migrating to React: Typed named routes in react-router and Typescript

Migrating to React: Typed named routes in react-router and Typescript

Author

Date

Category

Migrating to React: Typed named routes
Source

INTRODUCTION

If you’re a regular user of Codacy, you might have noticed a few changes over the course of this year on some pages. We’re currently in the process of migrating our entire product’s UI to React, gradually, and doing our best to make this process transparent for the user. But as you might wonder by now, this is not as straightforward as it seems. This is the first article of a series, where we’ll try to share our experiences and learnings throughout the course of migrating Codacy to React. 

THE PROBLEM

So, when we started our first problem was having the two frontends living side by side, in different environments, sharing the same domain and many times, the same URL. We won’t dive here into the server-side of this (spoiler: Kubernetes Ingress does a decent job at this), but instead, on how we managed to come up with a proper routing system for our SPA that could be resilient and scalable from scratch. 

So, our first problem was that using react-router, and having to support legacy routes would lead us to scenarios like this:

<SomeMenu>
     <Link to=”/repositories”>Repositories</Link>
     <a href={`${legacySiteUrl}/organizations`}>Organizations</a>
</SomeMenu>

 

If “Repositories” is an already migrated page, but “Organizations” is not, the user has to see two exactly identical links on screen, but behind the curtain, it would be a mess and a one-way ticket to technical debt. The horror. And let’s not even think about nested routers.

The second problem was not actually “a problem”, but a wish for a better solution and a seamless integration between the routing system and TypeScript. That would only mean using named routes for our router, once a react-router’s feature itself but removed years ago for a greater purpose. But not only named routes, typed named routes.

 

THE GOAL

The ultimate goal was being able to set a solution where we could do these things:

Linking to named routes without concerns about it being a React route or a legacy one:

<...>
     <Link to={routes.repositories}>Repositories</Link>
     <Link to={routes.organizations}>Organizations</Link>
</...>

 

Linking to named dynamic routes sending typed params (with TypeScript checking for missing params):

<Link to={routes.repository.dashboard} 
      params={{ provider: ‘gh’, name: ‘some-repo’ }}>
     Repository
</Link> // ok!

<Link to={routes.repository.dashboard} 
      params={{ provider: ‘gh’ }}>
     Repository
</Link> // compiler error, param ‘name’ missing in RepositoryParams

 

Picking up URL params also in a type-safe manner:

const { provider, name } = useParams<RepositoryParams>()

 

Being able to define all our routes in the same place, having a single source of truth for them, and making it really easy for us to migrate some pages in the future without refactoring every link to it:

const repositories : Route = {
   path: ‘/repositories’,
}

const organizations : Route = {
   path: ‘/organizations’,
   legacy: true,
}

 

And last but not least, being able to build new routes on top of existing ones. 

const organization: Route<OrganizationParams> = {
  path: '/:provider/:organization’,
}

const repository: Route<RepositoryParams> = from( organization, {
  path: '/:repository’,
}

 

Migrating to React: Typed named routes
Source

Easy, right? What could possibly go wrong?

 

THE (FIRST) SOLUTION

First of all, let’s declare our Route type:

export interface Route<T = {}> {
  path: string
  legacy?: boolean
  sampleParams?: T
}

Wait… what’s that sampleParams for? TypeScript’s type inference engine needs a type T attribute inside the interface to be able to work. So we need to add at least one attribute of type T

Now we can start declaring routes (nice!), but we also want to compose them. While composing, we’ll not only be adding a trailing path to the route, but we also have to extend T with the child’s T. In simpler words: if we create childRoute with paramsForChild on top of parentRoute with paramsOfParent, childRoute needs to ask for paramsForChild and for paramsForParent.

export function from<A = {}, B = {}>(base: Route<A>, add: Route<B>) {

  const { path, ...other } = add

  const result: Route<A & B> = {
    path: `${base.path}${path}`,
    sampleParams: base.sampleParams || sampleParams ? Object.assign({}, base.sampleParams, sampleParams) : undefined,
    ...other,
  }

  return result
}

 

Check that our result will be of type Route<A & B>, merging both param types together and therefore, asking for all of them later. And we also need to compose our fake attribute sampleParams (so the resulting type of it in the presence of two generic routes is also the inferred sum of both generic types). 

Also, and without extra effort, depending on the actual hierarchy of your application, you can nest routes within each other in your routes dictionary:

const organizationRoot: Route<OrganizationParams> = {...}
const repository: Route<OrganizationParams> = from(organizationRoot, {...})

// assign the child route as an actual child of its parent
const organization = Object.assign( organizationRoot, { repository })

// export all your routes!
export const routes = { organization }

 

Now you can use routes.organization, and routes.organization.repository. So far so good, but now we need our main component: the Link. Let’s take a look at its code:

...

import { NavLink } from 'react-router-dom'
import { generatePath } from 'react-router'

export interface LinkProps<T = {}> {
  children?: React.ReactNode
  to: Route<T>
  params?: T
  innerRef?: React.Ref<HTMLAnchorElement>
}

export function build<T>(route: Route<T>, params: T) {
  return generatePath(route.path, params && Object.fromEntries(Object.entries(params)))
}

export function legacy(path: string) {
  return `${process.env.REACT_APP_LEGACY_APP}${path}`
}

export default function Link<T>({ to, children, params, innerRef }: LinkProps<T>) {
  const path = params ? build(to, params) : to.path

  if (to.legacy) {
    return (
      <a href={legacy(path)} ref={innerRef}>
        {children}
      </a>
    )
  } else {
    return (
      <NavLink innerRef={innerRef} to={path} exact={to.exact}>
        {children}
      </NavLink>
    )
  }
}

 

This is just a Link abstraction to solve our first problem – remember how this article started? Depending on the route being a legacy one or not, we’ll either return a regular anchor, or a react-router’s NavLink. We are using and exporting two helper functions here to build the actual path, and the legacy path:

  • The legacy path helper grabs a path and adds the legacy base URL, stored in an environment variable.
  • The build path helper calls the react-router’s generatePath function, with a nasty hack to convert a typed object into the type generatePath expects.

And it’s done! Now we can use our Link component and our routes dictionary as we originally intended:

<Link to={routes.organizations}>Organizations</Link>

<Link to={routes.organization} 
      params={{ provider: ‘gh’, organization: ‘acme’ }}>
     ACME
</Link>

<Link to={routes.organization.repository} 
     params={{ provider: ‘gh’, organization: ‘acme’, repository: ‘some-repo’ }}>
     Some repository
</Link> 

 

And without really caring about having changes in their actual paths, or if they are routing going to another React page or to the legacy website. But what about the actual routing? Well, you don’t really need anything fancy or new for that; you just need to use the usual react-router’s Route component with your named routes’ path:

<Route path={routes.organization.path} component={OrganizationPage} />

And using the same useParams that react-router provides to get your params inside the page component:

const { provider, organization } = useParams<OrganizationParams>()

Migrating to React: Typed named routes
Source

IN THE WILD

This is a really simple first solution, and it should be enough for simple applications. It can even be simpler if you don’t really need supporting a legacy application and you just want a typed named router. But under Codacy’s concerns, the real world was a bit more complicated, and we started facing more complex scenarios. We started with this simple approach, and then we built on top of it.

FINAL WORDS

Possibilities on top of this solution are endless, and it might depend on the nature of your own application. From this starting point, you can think about adding a bunch of stuff like:

  • Type-safe search query params
  • Enabling or disabling routes 
  • Tie routes to environment variables or feature flags
  • Rewrite all react-router’s components (Route, Redirect, and so on)
  • Having a list of possible paths for a route (for A/B testing, beta testers, etc.)

Benefits were clear since the start, but since we started using type-safe named routes we don’t know how we used to live without them. In an ever-changing environment where URLs are shifting and products are evolving continuously, being able to change just one line of code and being sure that when compiling everything is in place, is priceless. 

Why not bundle this into a library? We thought about it, but as Ryan Florence said in the react-router’s named routes deprecation issue, “this is something you can do with no more than 20 lines of code”; and every application has different needs on routing, so writing your own type-safe named router tailored for your application, in particular, should be a no-brainer. Nevertheless, we’re still thinking about it!  😉

3 COMMENTS

  1. Hi Alejandro, thank you for the good article. I really enjoyed reading it and learned a few new things from you. The one code example where you define the function `from` in the 7th line you are referring to a variable `sampleParams` which wasn’t defined anywhere. Could it be that your intention here is to merge the `sampleParams` of the parent and the child routes? But it seems that line is only required to avoid type errors and the `sampleParams` is not used anywhere else, right?
    Anyway thank you for sharing your solution. In case you are interested: I also tried to make static type checking work with Typescript which I use for my projects. However it’s a balance act between supporting many features (query params, nested routes, etc…) and having a simple and clean interface. Maybe this is a reason why we don’t have any popular React libraries with support for statically typed routes. And we just need to wait for Typescript to evolve a little more. I linked to my Github repo here if this is okay with you. I’m always grateful for good suggestions.

    • Hi Denis! Thanks for your words! Yes, check the code above the block you mention. That variable, sampleParams, is only needed for TypeScript’s type inference to properly work. And when defining the from function, you need to do the same: return a composed sampleParams of the merge of both types for the nested route to also be able to infer the param types if present. Check the TypeScript FAQ article about type inference: https://github.com/microsoft/TypeScript/wiki/FAQ#why-doesnt-type-inference-work-on-this-interface-interface-foot– . In theory, you shouldn’t get any type errors without adding that variable, but for actual type checking to work you’d need to use the components specifying the generic type (writing something like <Link<OrganizationParams> ... />, for example).

  2. Great article! Finally someone is covering this topic.

    I don’t agree with the “20 lines of code” part, because clearly in your article you wrote a bit more than 20 lines of code. And those lines of code are not at all trivial, using advanced TypeScript to define them.

    By the way, another improvement I can think of that you could add to your list would be: to be able compose these routes with children with a nested route configuration rather than having to use `from()` which could be confusing if there are a lot of routes.

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Subscribe to our newsletter

To be updated with all the latest news, offers and special announcements.

Recent posts

21 AI Tools for Developers in 2023

A 2023 survey by Alteryx shows that 31% of companies using tools powered by artificial intelligence (AI) are using them to generate code. When asking...

Codacy Pioneers: A Fellowship Program for Open-Source Creators

Here at Codacy, we recognize the importance of the open-source software (OSS) community and are dedicated to nurturing and supporting it in any way...

AI-Assisted Coding: 7 Pros and Cons to Consider

According to a recent GitHub survey, 92% of developers polled said they are already actively using coding assistants powered by artificial intelligence (AI). AI-assisted...

Now Available. Centralized view of security issues & risk within Codacy

Codacy is empowering engineering teams to bring their security auditing process to the surface. Today we're giving all Codacy...