While working on a migration to React Router v6 recently, I hit a snag while trying to take advantage of the much-improved nested routes feature. This was in an application using MaterialUI, with a top-level layout component presenting MUI Tabs to the user.
The v5 wayโ
Before the v6-style nested layouts, I was passing the tab route to a TabbedLayout
component as a URL Parameter via dynamic segments
(see :selectedTab
in App.tsx
below). The TabbedLayout
component
was in charge of rendering the layout, and choosing which tab content component to render. As you can see, it's convenient to provide the :selectedTab
parameter to
TabbedLayout
, as it needs to know which tab is currently selected so it can highlight the correct tab:
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom"; import { useState } from "react"; import TabbedLayout, { tabs } from "./TabbedLayout"; export default function App() { return ( <BrowserRouter initialEntries={["tab1"]}> <Routes> <Route index element={<Navigate to={"tab1"} replace />} /> <Route path="/:selectedTab" element={<TabbedLayout />} /> </Routes> </BrowserRouter> ); }
React Router v6: all together, nowโ
I wanted to take full advantage of the v6-style nested routes, such that every single route a user could navigate to could be represented in one route layout:
<BrowserRouter>
<Routes>
<Route path="/*" element={<TabbedLayout />}>
<Route path="tab1" element={<Tab1Content />} />
<Route path="tab2" element={<Tab2Content />} />
<Route path="tab3" element={<Tab3Content />} />
</Route>
</Routes>
</BrowserRouter>
This is great - we still get our TabbedLayout
component handling our
layout/tabs around all of our Content components, and we don't even have to handle the
rendering logic for the Content components anymore. React Router will do that for us!
While React Router will handle the rendering of our content components automatically, we still need our
TabbedLayout
component to be aware of which tab route (tab1
, tab2
, or tab3
) is currently active, so it can highlight it.
There's only one problem - the TabbedLayout
component is rendered higher up in the
route tree than the tab selection routes (like <Route path="tab1" element={<Tab1Content />} />
)
Of course, we could just pass the :selectedTab
URL param as we did in the example above. But - I want to use these nested routes!
Basically, I have a component at a parent route (TabbedLayout
at route "/*
)
that needs to know if its child routes are active or not. I started by manually parsing the
location
object returned from React Router's useLocation
Hook, but this felt a bit hacky.
There is a better way!
Note: this is a special case as we're using MUI's Tab components. If you just need a Link
to know if it's currently active or not, check out React Router's NavLink
component.
useMatch
to the rescue!โ
It turns out you can use React Router's useMatch
Hook to see if the location we are currently at
matches a given path. Here's a crude use of it to determine which child route we're at, if any:
const useSelectedTab = () => {
const isTab1 = useMatch("/tab1");
const isTab2 = useMatch("/tab2");
const isTab3 = useMatch("/tab3");
if (isTab1) return "tab1";
else if (isTab2) return "tab2";
else if (isTab3) return "tab3";
return "tab1";
};
const TabbedLayout = () => {
const selectedTab = useSelectedTab();
return (
<Fragment>
<Tabs value={selectedTab}>
<Tab
label={tabs.TAB1.label}
value={tabs.TAB1.path}
to="tab1"
component={Link}
/>
<Tab
label={tabs.TAB2.label}
value={tabs.TAB2.path}
to="tab2"
component={Link}
/>
<Tab
label={tabs.TAB3.label}
value={tabs.TAB3.path}
to="tab3"
component={Link}
/>
</Tabs>
<Layout>
{/* This Outlet will render the TabContent as a child route */}
<Outlet />
</Layout>
</Fragment>
);
};
With a tiny wrapper function around the useMatch
Hook, we can make this a bit more concise:
function useRouteMatch(patterns: readonly string[]) {
const { pathname } = useLocation();
for (let i = 0; i < patterns.length; i += 1) {
const pattern = patterns[i];
const possibleMatch = matchPath(pattern, pathname);
if (possibleMatch !== null) {
return possibleMatch.pattern.path;
}
}
// default to `tab1`
return 'tab1';
}
const TabbedLayout = () => {
const selectedTab = useRouteMatch(['tab1', 'tab2', 'tab3']);
return (
<Fragment>
<Tabs value={selectedTab.pattern.path}>
{ // ... }
</Fragment>
);
};
When taken all together, this pattern can make it really easy to group your components with their paths and get the router to do the work for you. Play around with it here:
import * as React from "react"; import { Fragment } from "react"; import { Tab, Tabs } from "@mui/material"; import { Link, matchPath, Outlet, useLocation, BrowserRouter, Route, Routes, Navigate, } from "react-router-dom"; import Layout from "./Layout"; const Tab1Content = () => <div>Tab 1 Content</div>; const Tab2Content = () => <div>Tab 2 Content</div>; const Tab3Content = () => <div>Tab 3 Content</div>; export const tabs = { TAB1: { label: "Tab 1", path: "tab1", component: Tab1Content }, TAB2: { label: "Tab 2", path: "tab2", component: Tab2Content }, TAB3: { label: "Tab 3", path: "tab3", component: Tab3Content }, }; function useRouteMatch(patterns: readonly string[]) { const { pathname } = useLocation(); for (let i = 0; i < patterns.length; i += 1) { const pattern = patterns[i]; const possibleMatch = matchPath(pattern, pathname); if (possibleMatch !== null) { return possibleMatch.pattern.path; } } return "tab1"; } const TabbedLayout = () => { const tabPaths = Object.values(tabs).map((tab) => tab.path); const selectedTab = useRouteMatch(tabPaths); return ( <Fragment> <Tabs value={selectedTab}> <Tab label={tabs.TAB1.label} value={tabs.TAB1.path} to={tabs.TAB1.path} component={Link} /> <Tab label={tabs.TAB2.label} value={tabs.TAB2.path} to={tabs.TAB2.path} component={Link} /> <Tab label={tabs.TAB3.label} value={tabs.TAB3.path} to={tabs.TAB3.path} component={Link} /> </Tabs> {/* This Outlet will render the TabContent as a child route */} <Layout> <Outlet /> </Layout> </Fragment> ); }; export default () => { return ( <BrowserRouter> <Routes> <Route path="/*" element={<TabbedLayout />}> <Route index element={<Navigate to={"tab1"} replace />} /> <Route path={tabs.TAB1.path} element={<tabs.TAB1.component />} /> <Route path={tabs.TAB2.path} element={<tabs.TAB2.component />} /> <Route path={tabs.TAB3.path} element={<tabs.TAB3.component />} /> </Route> </Routes> </BrowserRouter> ); };