Migrating to React: Typed named routes in react-router and Typescript
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=> Repository </Link> // ok! <Link to={routes.repository.dashboard} params=> 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’, }
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=> ACME </Link> <Link to={routes.organization.repository} params=> 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>()
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! 😉