Frontend With HasanAbout Me

Upgrading React Router from v3 to v6

We all know it is important to keep our dependencies upto date specially when it comes to the ever changing world of Javascript, but there is always a deadline hanging over your head or the current package is working fine so why bother upgrading it.

Something like this happened to us in Sematext and we kept delaying upgrade of React Router. We were still using React Router version 3 while 6 came out with bunch of enticing features. To give you some context: Router v3.0.0 came out in late 2016 and v6.0.0 came out in late 2021!

By mid 2022 we decided that it was high time we upgraded and I like our readers decided to google how to upgrade from v3 to v6. But all articles I found were focused on upgrading it from v4/5 to v6. Of course who would be crazy enough to upgrade from v3 to v6 😅. This is partly the reason I wanted to write this article so I can share what its like upgrading a complex app from v3 to v6 and how to solve the problems you might face.

Major Changes

There were bunch of API changes between v3 and v6. I highly recommend reading the official migration guide. But as you can see from title, it is focused more on migrating from version 5. So I have compiled a list of major changes that you need to be aware of when upgrading from v3 to v6.

There are a lot of other changes like Switch (introduced in v4) is now called Routes and Redirect is now called Navigate, plus amazing API's like loaders and actions were introduced. But the main focus of this article is how we tackled the major challenges we faced in our large production app.

Our Specific Challenges

Creating your own withRouter HOC

In older apps its common to see old class based components living alongside new functional components. Our app still has many legacy class based components meaning that we still needed access to the withRouter HOC. But now its not natively provided. So this is one of the first things we had to do.

export type WithRouterProps = {
router: Router,
location: Location;
params: Params;
};

export default function withRouter(Component: React.ElementType) {
function ComponentWithRouterProp(props: any) {
const location = useLocation();
const navigate = useNavigate();
const params = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const router = {
location, params, navigate, searchParams, setSearchParams,
};
return (
<Component
{...props}
location={location}
params={params}
router={router}
/>
);
}

return ComponentWithRouterProp;
}

Using this HOC we were able to access router props in our class based components.

In React Router you can not use hooks like useNavigate outside of Router's context. But it is common for apps to have navigation in things like Redux Thunks. This is one of the most difficult things to solve in v6. You can get an idea how contentious this issue is by looking at the Github issue for it. People from the core team have suggested different solutions including using things like unstable_HistoryRouter. So for this important use-case we decided to use our novel approach which works great for us.

We basically decided to rely on native JS custom events and event listeners. You can see what the code looks like below:

RouterEventListener.ts
import { useEffect } from 'react';
import {
NavigateOptions,
To,
useNavigate,
} from 'react-router-dom';
import {
addListener,
NAVIGATE,
removeListener,
} from 'utils/events';

type RouterEventLoad = {
to: To;
options?: NavigateOptions;
};

function navigateMethod(props: RouterEventLoad) {
this.navigate(props.to, props.options);
}

const RouterEventListener = (): null => {
const navigate = useNavigate();
useEffect(() => {
addListener(NAVIGATE, navigateMethod, { navigate });
return () => removeListener(NAVIGATE, navigateMethod);
}, [navigate]);

return null;
};

export default RouterEventListener;
mainRouterNavigate.ts
import { To, NavigateOptions } from 'react-router-dom';

export const mainRouterNavigate = (to: To, options?: NavigateOptions) => {
emitEvent(NAVIGATE, { to, options });
};

We are using the eventemitter3 package but you don't need it to accomplish the same. With this approach you are free to navigate anywhere in your app by just calling mainRouterNavigate(to, options) and making sure that <RouterEventListener /> is inside Routers context. The one advantage of using this approach compared to the ones mentioned in official repo is that you are free to use the latest data router and you avoid any cyclic dependencies.

Nesting Routers

Our project utilized more than one router. In fact the same routes are shared across multiple routers so that you can create a single page that you can open in the main view, split view or in a flyout. Whereas most of these are direct children of the parent component but split view router is part of the main router. Before you could nest routers but that is now prohibited. So how do we solve this problem?

Enter React Portals! We create the split view router at the parent component as well but its content is rendered inside the main router using React Portal.

Passing Data in Routes

Pre-upgrade we relied heavily on passing data in routes. For example all our breadcrumbs were constructed by passing specific breadcrumb to each relevant route in a path. In v3 it was easy for us to just append whatever data we needed for that route in the actual route configuration. But when v6 initially launched it had no such facility. But with the introduction of data router they introduced handle property plus useMatches API for accessing route specific data. Now we have Routes like this:

routes.js
<Routes>
<Route path=":userId" element={<Profile />} handle={{ breadcrumb: 'Profile' }}>
<Route path=":postId" element={<Post />} handle={{ breadcrumb: 'Post' }} />
</Route>
</Routes>

Then you can you have a breadcrumb component like so:

Breadcrumb.tsx
import { useMatches } from 'react-router-dom';

function useBreadcrumbs(): Array<string | React.ReactNode> {
const handle = useRouteHandle();
return handle.filter((h) => h?.breadcrumb).map((h) => h.breadcrumb);
}

const Breadcrumb = () => {
const breadcrumbs = useBreadcrumbs();
return (
<div>
{breadcrumbs.map((breadcrumb) => (
<span>{breadcrumb}</span>
))}
</div>
);
};

Solving circular dependencies

Because this blog is about upgrading a large app, from v3 to v6, it means that you will have some legacy in your codebase. And if your app is structured in a way that you have to import the same thing in multiple places there is a possibility that you will run into circular dependency issues. You can also run into this issue if you decide to import your main router object in different files for navigation.

To solve circular dependency the correct approach is to refactor your codebase so that you import the common stuff from a single file. With correct modularization you can avoid this issue altogether.

But that might not be possible for your codebase or it may require a mammoth effort in an already large router upgrade. So as a last resort I will reluctantly suggest the following approach where we create a router object initially and inject the the newly created router object into it with the following approach:

// Smaller module relying on main router
let router = null;

export function injectSplitScreenRoutes(injectedRoutes) {
router = createMemoryRouter(createRoutesFromElements(
<Route path="/" element={<VizSplitScreenContainerWithReport />}>
{injectedRoutes()}
</Route>
));
}

// Main router

import { injectSplitScreenRoutes } from 'utils/splitScreenRoutes';

const routes = ....

injectSplitScreenRoutes(routes);

I would reiterate that this should be a last resort, either you should not have circular dependencies or you should refactor your codebase to avoid them. But if you get stuck like we did then you can look at this approach. (I am not proud of it 😅)

Conclusion

In this blog post I have tried to share the major pain points of upgrading from v3 to v6 and how to solve those. React Router v6 is a great upgrade and with new data routers it has opened a lot of useful features. Hopefully this article can help people who like us who were stuck on v3 still. Happy coding! 🚀