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 usinghistory
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.
- 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.
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
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.
<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.
You can create a commit at this point. No functional changes yet.
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.
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.
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.
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 Route
s matters. However, in v6 this is no longer a problem, so the exact
attribute is no longer needed.
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>
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>
);
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)
}
/>
}
>
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.
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>
)
}
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.
Your tests will likely need a few changes in order to pass. Luckily these are generally pretty straightforward and fall into several categories:
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.
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();
});
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,
}
);
Some things to nail down:
compatRoute
prop.acore-utils
if we want to provide a centralized solution for it.