Jul 01, 2024

Migrating all the way up to React Router v6.4+ with Data APIs

{post.author}
by Vitor Buzinaro

First, a disclaimer: this post tells the whole story from Backbone Router up to React Router v6.4+ with data routes. You can jump to Upgrading React Router from v5 to v6.4+ if you came here only for the React Router upgrade.

A bit of history

Our story begins in 2012, when React wasn’t the main way of writing apps on the frontend. Actually, it wasn’t even open source yet. Maybe it was just an embryo inside Meta (Facebook, at that time). So our app was written in Backbone, and the router of our choice was – by consequence – Backbone Router.

Eventually, in 2017, after watching a few frameworks rise and fall, when React was already dominating the market, we felt the need to modernize our app and introduce it to our codebase.

The first step: tooling. At that time, we were using Grunt to bundle our code, AMD modules with RequireJS and ES5. So we migrated to Webpack, ES2015 modules and ES2015+ respectively. This was actually my first big project when I started at Close a bit more than 7 years ago.

Then, we decided that every new feature would be written in React and all existing ones would be migrated to React incrementally, as we needed to maintain them. Sometimes it wouldn’t make sense to migrate an entire page just to add a new small feature. So we would just implement the new feature in React and render it inside the Backbone view.

And for a few years, we added more and more features in React, up to the point that it made sense to flip: instead of a big Backbone view rendering other Backbone views and React components, our app basically became a React tree that would eventually render old Backbone views where we were not able to migrate just yet. But even then, we were still using Backbone Router.

At this point, a few years ago, before Remix (and React Router v6.4+ data routes) didn’t exist yet, we decided to migrate our Backbone Router to React Router v5 – v6.0 was still in beta.

Migrating from Backbone Router

In order to do such a big migration incrementally, we decided to start with our Settings sub-router. In general, settings pages are much simpler than the rest of the application, and they don’t rely too much on many routing-related features. Also, since it’s entirely a sub-layout, we could take advantage of React Router’s nested layouts upfront.

So we did.

Steps

  • First we had to wrap any settings page that were still Backbone views into React components.
  • Then, since we would still have two routers at the same time, we had to monkey-patch Backbone.history to actually call React Router’s history under the hood.
  • We also updated a few of our hooks and components (useNavigateTo, useCurrentPath, InternalLink etc) in order to work with both libs.
  • We created a ReactRouterAdapter component for easy interoperability.

The rest

Our POC under a feature flag worked super well, so after shipping it publicly, we had to plan the rest of the migration. It took us some time to get back to it, but eventually we did it. We just had to replicate the same thing we did to Settings to the rest of our app, until there was no Backbone Router remaining, and we could completely kill our code that made them work well together.

At this point, React Router v6 had already launched, and the Remix team had just “remixed” React Router with its v6.4+ Data APIs. So it made sense to us to go ahead and continue our path.

Upgrading React Router from v5 to v6.4+

After migrating Backbone Router to React Router v5, I thought that “just a library update” would be much simpler and straight forward, but sometimes a major version upgrade is almost as painful as migrating to a different library.

The Good

React Router has a great guide for who is upgrading from v5. They provide a package (react-router-dom-v5-compat) that aims to make v5 compatible with v6 APIs, so you can “one component, one hook, and one route at a time” (their words). It’s very detailed and well-written, and since it was done by none other than Ryan Florence, I couldn’t expect anything less!

The Bad

When migrating from BB Router to React Router, in order to be able to do it incrementally, our approach was to create a router for each subsection of our app, and then eventually those routers became sub-routers of the main router that we called AppRouter. The compatibility package works super well for a single router, but once you have multiple sub-routers, they go back to v5 mode, and – unless we’re missing something – you can’t really use Routes as you would in v6 (at least, after several tries, we weren’t able to do it).

The Ugly

So after using CompatRouter for our main router (AppRouter), we had to basically migrate all sub-routers to v6 and stop using CompatRouter at once, on a single PR:

PR Stats: 231 files changed – 3,422 lines added and 4,838 removed

The problems with a big PR like this are:

  • Even if you split into atomic and meaningful commits, it’s just hard to review at once.
  • Whenever the fix for a bug is holistic, you need to make sure that everything else affected still works. So, even when having a good amount of E2E and integration tests as we do, it’s just impossible to cover everything, so it’s hard to be confident that everything works as it should.
  • Since it’s a big PR and it takes some time to review, test, iterate, re-test etc, the amount of rebases due to other people touching the same file becomes frequent.
  • After you ship, if you find a deal-breaker bug and other people have touched the same files afterwards, it can be painful to revert.

All that said, the upgrade was a success. It took more time than anticipated to migrate the entire app, and there was some struggle here and there due to the fact that routing basically touches every single page of your app, but in the end, we had great results.

First, by moving the routing code to a single file (in order to leverage createBrowserRouter) and by using loaders to handle a bunch of redirect logic we had, we were able to remove 1.4K LOC as you can see on the image above.

Second, we could significantly improve the performance, by reducing the waterfalls:

Comparing RRv6.4+ with Data APIs vs RRv5

On the left, we’re preloading the JS/CSS modules (via requestIdleCallback), we’re fetching only the main endpoint on our loader (and using defer), then we render the page where finally some components fetch their own stuff on render. On the right, first we load the modules, then we render the page where all components fetch their own stuff.

Third, we could prepare ourselves for what’s next: React Router v7.

What’s next

When we started upgrading to v6.4+, the main idea was to leverage the Data APIs in order to reduce the waterfalls, by being able to fetch data before rendering our components.

A second goal was to prepare ourselves for the future. The team behind React Router / Remix was working hard to bring back all Remix goodies back to React Router and also make it possible to use Remix as a client-side app, with its Vite plugin and its new SPA mode.

A few months back they announced that React Router and Remix are being merged into React Router v7, which was music to our ears, since it makes even easier to modernize our codebase.

File-based routing

While doing this migration, we were already planning the next steps. So, when moving our routers, we were already following the convention used by Remix and placing all routes on routes/*:

IDE with Remix-style file-based routing

Vite

Since 2017 we’ve been using Webpack for building/bundling our app, but we’ll migrate to Vite, in order to be ready for React Router v7.

React Router v7

At this point, we’ll be able to just upgrade to v7 and take advantage of many of its goodies:

  • Route loaders, actions, and automatic data revalidation
  • Type-safe Routes Modules
  • Type-safe Route paths across the app
  • Automatic route code-splitting
  • Automatic scroll restoration across navigations
  • Preload modules/data on intent (e.g. hovering a link)
  • Optional Static pre-rendering
  • Optional Server rendering
  • Optional React Server Components

Stay tuned!