Skip to content

Instantly share code, notes, and snippets.

@jasonaden
Last active August 2, 2023 19:02
Show Gist options
  • Save jasonaden/99d69fe0262da39949dca10f1224334e to your computer and use it in GitHub Desktop.
Save jasonaden/99d69fe0262da39949dca10f1224334e to your computer and use it in GitHub Desktop.
Upgrade React Router to v6

Upgrade React Router to v6

React Router recently made a major shift with the v6 router. There are some key improvements we want to take advantage of. Some of these include:

  • More intelligent routing, not relying on the exact prop or the order of the route definitions.
  • Relative routes and linking. No more parent route paths for nested routes.
  • New Outlet component, allowing us to decide where to render child routes.
  • Smaller bundle sizes
  • Suspense-aware navigation using the new navigate function (rather than using history API in v5)
  • In the most current versions of React Router, a new set of data-enabled APIs have been added, giving us access to functionality like pre-route loading, route-level form handling, and better route error handling.

Example PRs

  • Reference UI - First MFE converted. Relatively straightforward conversion.
  • Tag UI - Also mosly straightforward, but introduces the need for conditional routing with authenticated routes.
  • Segmentation UI - Example of fixes for unit tests.

Upgrade Process

There are several documents on the React Router site relating to upgrading to v6, but not all of them are particularly useful. Below are some distilled instructions we’ve found successful for an incremental upgrade process. For reference though, these are the docs we used to start the migration process and can be used for debugging:

Direct upgrade to v6 (no compatibility mode): https://reactrouter.com/en/main/upgrading/v5

Compatibility Mode Upgrade (gradual upgrade process): remix-run/react-router#8753

1. Install Compatibility Package react-router-dom-v5-compat@^6.14.2

Replace any references in your package.json file to either react-router or react-router-dom with react-router-dom-v5-compat at version 6.14.2. Note that since v5 of react-router, application code shouldn't import from react-router directly and should only install and/or import from react-router-dom. You should additionally remove references to @types/react-router-dom or @types/react-router since these are included with the v6 package.

2. Add CompatRouter if necessary

<CompatRouter> should be the first child of <Router> or <BrowserRouter> components. For most MFEs, this should already be done by adding <CompatRouter> to client-ui, so this step may be unnecessary:

 import { BrowserRouter } from "react-router-dom";
+import { CompatRouter } from "react-router-dom-v5-compat";

 export function App() {
   return (
     <BrowserRouter>
+      <CompatRouter>
         <Switch>
           <Route path="/" exact component={Home} />
           {/* ... */}
         </Switch>
+      </CompatRouter>
     </BrowserRouter>
   );
 }

The CompatRouter grabs access to history from the router v5 and wires up a controlled v6 router so both v5 and v6 are talking to the same history instance.

3. Commit - should pass CI

You can create a commit at this point. No functional changes yet.

4. Update imports to react-router-dom-v5-compat

Any imports pointing to either react-router or react-router-dom should be updated to point to react-router-dom-v5-compat. You should be able to do this with a find/replace focused on the MFE and/or library you are migrating. You will likely see TypeScript errors after doing this, but it's the fastest way to identify many of the code locations that need to be changed.

5. (Maybe) Remove <AppRouter> from your MFE

For any MFEs that have been upgraded, we don't need the AppRouter in the MFE. This functionality, including the CompatRouter, has been moved up to client-ui and so it's not needed in the MFEs.

6. (Likely) Add compatRoute Property to your LazyMFERoute

When updating an MFE to v6 router, you should find any the LazyMFERoute that renders your MFE. For most this will be in the AppRoutes.tsx file in client-ui. You need to add the compatRoute prop to this component, which will cause your MFE to be rendered in a CompatRoute component rather than the default Route component from the v5 router.

7. Change <Switch> to <Routes>

In the v6 router, the new Routes component is a direct replacement for Switch. However, with the v5 router the Switch component can render multiple routes at once. This is why we sometimes see the exact attribute added to Route components, or why the sequence of Routes matters. However, in v6 this is no longer a problem, so the exact attribute is no longer needed.

8. Update <Route> and <Redirect> components to v6 API

Route component paths are now relative, so there are several updates you might need to do. First, remove any reference to exact as this is not a valid attribute for v6. Next, update routes to be relative. The top-level root route will still be /, but the root route in any given MFE should be an empty string. Update any other paths to be relative routes. They should all be relative to the root of your MFE. If you prefix a route with /, the router will route to the root of the application (Home) first.

Redirect should be replaced with Navigate. There are a couple differences in behavior between the two. The first is that Redirect by default will use a replace strategy for history and Navigate uses push. So to get the same history behavior use Navigate with replace={true} prop. The second difference is the old Redirect would immediately redirect in the same render cycle. This isn't recommended to change router state on first render, so the behavior has changed such that a Navigate on first render will happen inside a useEffect. This shouldn't affect the UI, but could cause a difference in test behavior.

For example:

<Routes>
-  <Route path="/" component={Welcome} />
+  <Route path="" element={<Welcome />} />
-  <Route path="/more" component={More} />
+  <Route path="more" element={<More />} />
-  <Route path="/dismiss-modal" component={DismissModal} />
+  <Route path="dismiss-modal" element={<DismissModal />} />
   <Route
-    path="/graphql/*"
+    path="graphql/*"
     element={
       <AuthorizedReactRouter6Route permission={Permission.ReferenceMfeAccess}>
         <GraphQLExamplesRoutes />
       </AuthorizedReactRouter6Route>
     }
   />
   <Route
-    path="/graphql-explorer"
+    path="graphql-explorer"
     element={
       <AuthorizedReactRouter6Route permission={Permission.ReferenceMfeAccess}>
         <GraphQLExplorer />
       </AuthorizedReactRouter6Route>
     }
   />
-  <Route path="*" render={<Redirect to="/more">} />
+  <Route path="*" element={<Navigate to="./more" replace={true} />} />
</Routes>

Conditional Routes

Many of our routes are conditional routes, relying on either a feature flag or permissions or both. React Router 6 doesn't allow children of Routes or Route to be anything other than a Route component. Therefore we can't use conditional logic like we previously had and need to conditionally render the element property of a route. One advantage though in RR 6 is you can have a Layout Route without a path defined. This is typically where you will apply the conditional. So, for example, this is the transformation of a route in Settings UI:

-      {ENABLE_EMAIL_MGMT_PAGES && (
-        <SettingsPageRoute
-          exact
-          path={Route.EMAIL_TEMPLATES}
-          sectionName={SectionName.EMAIL_TEMPLATES}
-        >
-          <EmailTemplatesPage />
-        </SettingsPageRoute>
-      )}
+      <Route
+        element={
+          ENABLE_EMAIL_MGMT_PAGES && <SettingsPageRoute sectionName={SectionName.EMAIL_TEMPLATES} />
+        }
+      >
+        <Route path="email-templates" element={<EmailTemplatesPage />} />
+      </Route>

The SettingsPageRoute has also been modified to render an Outlet component, which will render the child Route component in place of the Outlet. This is the change to SettingsPageRoute:

  return (
    <SlotNameProvider value={sectionName}>
      <RouteErrorBoundary sectionName={sectionName}>
-       <PageRoute {...props} />
+       <Outlet />
      </RouteErrorBoundary>
    </SlotNameProvider>
  );

Permissions in Routes

For route permissions you can use the new RoutePermissions component, which takes a permission prop. You can pass a permission, or a predicate function so you can combine permissions with feature flags:

<Route element={<RoutePermissions permission={Permission.ConciergeSettingsAccess} />}>
  <Route element={<SettingsPageRoute sectionName={SectionName.CONCIERGE_SETTINGS} />}>
    <Route path="concierge-settings" element={<ConciergeSettingsPage />} />
  </Route>
</Route>

Or:

<Route
  element={
    <RoutePermissions
      permission={(checkPermission) =>
        !ONBOARDING_REGION_MGMT || checkPermission(Permission.SuperUserAccess)
      }
    />
  }
>

9. Change component code to use v6 instead of v6 APIs

Routes need to use the v6 routing context.

πŸ‘‰ Read from v6 useParams() instead of v5 props.match

+ import { useParams } from "react-router-dom-v5-compat";

  function Project(props) {
-    const { params } = props.match;
+    const params = useParams();
     // ...
  }

πŸ‘‰ Read from v6 useLocation() instead of v5 props.location

+ import { useLocation } from "react-router-dom-v5-compat";

  function Project(props) {
-    const location = props.location;
+    const location = useLocation();
     // ...
  }

πŸ‘‰ Use navigate instead of history

+ import { useNavigate } from "react-router-dom-v5-compat";

  function Project(props) {
-    const history = props.history;
+    const navigate = useNavigate();

     return (
       <div>
         <MenuList>
           <MenuItem onClick={() => {
-            history.push("/elsewhere");
+            navigate("/elsewhere");

-            history.replace("/elsewhere");
+            navigate("/elsewhere", { replace: true });

-            history.go(-1);
+            navigate(-1);
           }} />
         </MenuList>
       </div>
     )
  }

There are more APIs you may be accessing, but these are the most common. Refer to React Router docs if you come across another API you need to swap out.

10. Update Links and NavLinks

Some links may be building on match.url to link to deeper URLs without needing to know the portion of the URL before them. You no longer need to build the path manually since React Router v6 supports relative links.

πŸ‘‰ Update links to use relative to values

- import { Link } from "react-router-dom";
+ import { Link } from "react-router-dom-v5-compat";

  function Project(props) {
     return (
       <div>
-        <Link to={`${props.match.url}/edit`} />
+        <Link to="edit" />
       </div>
     )
  }

The way to define active className and style props has been simplified to a callback to avoid specificity issues with CSS:

πŸ‘‰ Update nav links

- import { NavLink } from "react-router-dom";
+ import { NavLink } from "react-router-dom-v5-compat";

  function Project(props) {
     return (
       <div>
-        <NavLink exact to="/dashboard" />
+        <NavLink end to="/dashboard" />

-        <NavLink activeClassName="blue" className="red" />
+        <NavLink className={({ isActive }) => isActive ? "blue" : "red" } />

-        <NavLink activeStyle={{ color: "blue" }} style={{ color: "red" }} />
+        <NavLink style={({ isActive }) => ({ color: isActive ? "blue" : "red" }) />
       </div>
     )
  }

11. Rinse and Repeat up the tree

Once your deepest Switch components are converted, go up to their parent <Switch> and repeat the process. Keep doing this all the way up the tree until all components are migrated to v6 APIs.

When you convert a <Switch> to <Routes> that has descendant <Routes> deeper in its tree, there are a couple things you need to do in both places for everything to continue matching correctly.

πŸ‘‰οΈ Add splat paths to any <Route> with a descendant <Routes>

  function Root() {
    return (
      <Routes>
-       <Route path="/projects" element={<Projects />} />
+       <Route path="/projects/*" element={<Projects />} />
      </Routes>
    );
  }

This ensures deeper URLs like /projects/123 continue to match that route. Note that this isn't needed if the route doesn't have any descendant <Routes>.

πŸ‘‰ Convert route paths from absolute to relative paths

- function Projects(props) {
-   let { match } = props
  function Projects() {
    return (
      <div>
        <h1>Projects</h1>
        <Routes>
-         <Route path={match.path + "/activity"} element={<ProjectsActivity />} />
-         <Route path={match.path + "/:projectId"} element={<Project />} />
-         <Route path={match.path + "/:projectId/edit"} element={<EditProject />} />
+         <Route path="activity" element={<ProjectsActivity />} />
+         <Route path=":projectId" element={<Project />} />
+         <Route path=":projectId/edit" element={<EditProject />} />
        </Routes>
      </div>
    );
  }

Usually descendant Switch (and now Routes) were using the ancestor match.path to build their entire path. When the ancestor Switch is converted to <Routes> you no longer need to do this this manually, it happens automatically. Also, if you don't change them to relative paths, they will no longer match, so you need to do this step.

12. Testing

Your tests will likely need a few changes in order to pass. Luckily these are generally pretty straightforward and fall into several categories:

ReactRouter6Decorator

Where you're currently using the ReactRouterDecorator you should switch to ReactRouter6Decorator. For the most part, this can just be included within your decorators array in the .storybook/preview.ts file.

MFE Test

Most MFEs are initially tested in a file named after the MFE, such as SettingsMfe.test.tsx. Most of these utilize the renderWithProviders function, then render the MFE app with baseURL and history props. You should be able to simply delete these props since we don't write directly to history in RR6. This will result in a TypeScript error since it won't match the props of MFE apps, but just ignore this error for now. For example:

-  it('renders without crashing', async () => {
-    renderWithProviders(<SettingsApp baseURL="/" history={history} />, {
-      companyFeatureFlags: {
-        ...sidebarCompanyFeatureFlags,
-        ENABLE_VIRTUAL_CONTACT_CARD: false,
-        DAMPEN_CREATIVE_ADA_VALIDATION: false,
-      },
-    });
+    renderWithProviders(
+      // @ts-expect-error - Not passing route props as this MFE is upgraded to RR6
+      <SettingsMfe />,
+      {
+        companyFeatureFlags: {
+          ...sidebarCompanyFeatureFlags,
+          ENABLE_VIRTUAL_CONTACT_CARD: false,
+          DAMPEN_CREATIVE_ADA_VALIDATION: false,
+        },
+      }
+    );
    expect(await screen.findByRole('heading', { name: 'Company' })).toBeInTheDocument();
  });

Initial Route

Many places require an initial route to be rendered for the test to run. Previously we would have done this by creating a history object and providing this to the test decorator. However, with RR6 we don't interact with history directly. Instead you should set up your route and then navigate to it using an index route. Here is an example diff:

// In the render function of a test...

- renderWithProviders(
-   <Switch>
-     <Route path={Route.SSO_MANAGEMENT} component={SSOManagementPage} />
-   </Switch>,
+   <Routes>
+     <Route path="sso-management" element={<SSOManagementPage />} />
+     <Route index element={<Navigate to="sso-management" />} />
+   </Routes>,
   {
-     history: createMemoryHistory({
-       initialEntries: [Route.SSO_MANAGEMENT],
-       initialIndex: 0,
-     }),
     companyFeatureFlags,
   }
 );
@jasonaden
Copy link
Author

jasonaden commented Jul 19, 2023

Some things to nail down:

  • Need to update the part about the root of MFE. Add note about the compatRoute prop.
  • Look at the v6 router decorator from Sean's PR and see if we want to pull this in.
  • Add a section on updating/fixing tests.
  • Discuss how we want to handle conditionals and add this to acore-utils if we want to provide a centralized solution for it.

@jasonaden
Copy link
Author

{/* {conversationsRouteEnabled && (
            <LazyMFERoute
              path={Routes.Conversations}
              appName="conversations-ui"
              title="Conversations"
            />
          )} */}
          <Route
              path='conversations' element={conversationsRouteEnabled && <LazyMFERouteV6
                appName="conversations-ui"
                title="Conversations"
              />}
            />

@jasonaden
Copy link
Author

@jasonaden
Copy link
Author

import React, { useEffect } from 'react';
import { Navigate, Route, Routes, Outlet } from 'react-router-dom-v5-compat';
import { useAtom } from 'jotai';
import { NotFound, useCurrentUser, usePermission } from '@attentive/acore-utils';
import { Permission } from '@attentive/data';
import { Overview } from './routes/overview';
import { Edit } from './routes/edit';
import { Releases } from './routes/releases';
import { companyIdAtom } from './routes/state/companyAtoms';

export function TagMfeRoutes() {
const user = useCurrentUser();
const [, setCompanyId] = useAtom(companyIdAtom);

useEffect(() => {
setCompanyId(user.company.internalId);
}, [setCompanyId, user.company]);

return (
<>

<Route element={}>
<Route path="overview" element={} />
<Route path="edit" element={} />
<Route path="edit/advanced" element={} />
<Route path="releases" element={} />

  <Routes>
    <Route path="not-found" element={<NotFound />} />
    <Route path="" element={<Navigate replace to="/tag/overview" />} />
  </Routes>
</>

);
}

type VerifyAuthProps = {
permission: Permission;
children?: never;
};

function RoutePermissions({ permission }: VerifyAuthProps) {
const isAuthorized = usePermission(permission);
return isAuthorized ? : ;
}

@daniellealexis
Copy link

Could use a small section on removing @types/react-router-dom as a dependency!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment