When faced with a challenge of implementing breadcrumbs for a business critical application recently I went down a rabbit hole of trying to understand the semantics of react-router and finding a good way of building a dynamic breadcrumb component that didn't break every time a route was added or changed. Let alone need to implement a custom route for every new page. In this post I go into what I ended up with as a routing model that supports dynamic breadcrumbs
The requirements
- Maintain a single routing model (or composition of models) as the source of truth for the app
- Not have to manually maintain breadcrumbs for different pages
- Support child routes
- Use the same model to generate bread crumbs for the currently active page.
- Be able to show dynamic breadcrumb titles based on parameters.
- Bonus: Support generating Navlinks
TLDR;
You can check out this github repository to see my trail and error: https://github.com/pavsaund/react-routing-model/
You can view the code in action on stackblitz: https://stackblitz.com/github/pavsaund/react-routing-model/
Digging into details
It took me a while to really grok the routing model with nested routes in React Router v6. I put this down to coming from very basic use of v5 and mostly using other frameworks. I found this article on nested routes most useful https://ui.dev/react-router-nested-routes. Based on this I realized I wanted to define my routes as a single model, where possible and to use the <Outlet />
component to render the routes for a given path. More info on the usage of <Outlet />
.
Let's start with what the routes look like from a React Router perspective, and what you'll likely see in your regular react app.
<Routes>
<Route path="/" element={<Page title="home" />} />
<Route path="/away" element={<Page title="away" />} />
<Route path="/sub" element={<Page title="sub" withOutlet />}>
<Route path="zero" element={<Page title="sub-zero" />} />
</Route>
</Routes>
I started with the model I wanted, which was built separately from React Router's. The idea is that a simple model can easily be parsed and mapped into something React Router could understand. I didn't want to implement ALL the features of React Router, but just enough for my use case. This worked fine for the initial proof of concept. Then after experimenting a bit and also understanding more of the route model that React Router expected I actually ended up augmenting the RouteObject
model with custom properties. This is the end result.
export interface RoutePathDefinition extends RouteObject {
title: string;
nav?: boolean;
children?: RoutePathDefinition[];
path: string;
};
const routes: RoutePathDefinition[] = [
{
title: "Home", path: "/", element: <Page title="home" />,
},
{
title: "Away", path: "/away", element: <Page title="away" />,
},
{
title: "Sub",
path: "/sub",
element: <Page title="sub" withOutlet />,
children: [
{
title: "Sub-Zero", path: "zero", element: <Page title="sub-zero" />,
},
],
}
];
The <Page />
-component is a simple helper component to render a page with a title, and the withOutlet
prop is an indication to render a <Outlet />
component for the child routes to render. Implementation here.
Building the breadcrumbs
Now, for the fun part - actually understanding how to get the active path from React Router. This is where grokking how React Router builds its paths were important. I realised after hitting my head on the wall that there is no central place where all the routes are stored that is exposed through public API. (There is an exposed UNSAFE_RouteContext
if you want to live on the edge). My current understanding is that React Router and nested routes seem to work by each level of the router owning its own routes and the next level to take over. Meaning that a parent route doesn't actually know anything about its children, and a child only knows its own path pattern based on the resolved parent's route. Now to build the breadcrumb.
Matching the top-level crumb with matchPath
Using the matchPath
utility the React Router will match the given location against the path provided. It also returns the resolved pathname, and any params it resolves. By specifying end = false;
on the PathPattern
option will allow a partial match on the supplied location. This allows us to know if a given pattern is part of the current location, and should be included in the breadcrumb or not.
So, let's resolve the top-level paths aginst to our second route /sub/zero
const location = useLocation(); //for '/sub/zero'
matchPath({path: '/', end: false, },location.pathname); // returns match
matchPath({path: '/away', end: false, },location.pathname); // returns null
matchPath({path: '/sub', end: false, },location.pathname); // returns match
Great, so this means that both the Home
and Sub
paths match and can be added to our breadcrumb. Like so:
function matchRouteDefinitions(
definitions: RoutePathDefinition[],
locationPathname: string
): PathMatch[] {
const crumbs: PathMatch[] = [];
definitions.forEach((definition, index) => {
const match = matchPath(
{ path: definition.path, end: false },
location.pathname
);
if (match) {
crumbs.push(match);
}
});
return crumbs;
}
const matches = matchRouteDefinitions(routes, '/sub/zero');
/** simplified matches
* [
* {pattern: '/'},
* {pattern: '/sub'}
* ]
* /
Matching children
So, now how can we match the zero
child route? Let's manually match again
const location = useLocation(); //for '/sub/zero'
matchPath({path: 'zero', end: false, },location.pathname); // returns null
matchPath({path: '/sub/zero', end: false, },location.pathname); // returns match
OK! Now we're getting somewhere. It's not enough to match against the path pattern itself, you also need to match with the parent pathname. So let's add the parent path into the mix.
function joinPaths(paths: string[]): string {
return paths.join("/").replace(/\/\/+/g, "/");
}
function matchRouteDefinitions(
definitions: RoutePathDefinition[],
locationPathname: string,
parentPath: string = ''
): PathMatch[] {
const crumbs: PathMatch[] = [];
const pathPatternWithParent = joinPaths([parentPath, definition.path]);
definitions.forEach((definition, index) => {
const match = matchPath(
{ path: pathPatternWithParent, end: false },
location.pathname
);
if (match) {
crumbs.push(match);
if (definition.children) {
const nestedMatches = matchRouteDefinitions(
definition.children,
locationPathname,
pathPatternWithParent
);
crumbs.push(...nestedMatches);
}
}
});
return crumbs;
}
const matches = matchRouteDefinitions(routes, '/sub/zero');
/** simplified matches
* [
* {pattern: '/'},
* {pattern: '/sub'}
* {pattern: '/sub/zero'}
* ]
* /
There's a bit more going on here so let's break down what's happening.
parentPath
has been added as a parameter with a default value of ''
. Then using the joinPaths
function the parent and definition path are joined, and any redundant //
are replaced with a single slash.
Next, if there are children on the matched route, then recursively call the matchRouteDefinitions
with the child routes. This time we pass in the pathPatternWithParent
as the parentPath
parameter, which then allows the child router paths to match.
Now, this is the happy path (pun intended 😏) implementation. There are some edge cases you may or may not want to support.
Edge case 1: Don't match breadcrumb for /
- Home route
For my use case, I didn't want Home
to show up, so I added another path check before deciding to add the path match
//...
definitions.forEach((definition, index) => {
//...
if (match && definition.path != '/') {
crumbs.push(match);
}
//...
});
const matches = matchRouteDefinitions(routes, '/sub/zero');
/** simplified matches
* [
* {pattern: '/sub'}
* {pattern: '/sub/zero'}
* ]
* /
Edge case 2: Don't match a no-match/catch-all route
It's common to add a NoMatch route to serve a user with a 404 page of some kind. The problem is that this route will match anything - which is kind of the point.
routes.push({
title: "404", path: "*", element: <Page title="404 Not Found" />,
});
const matches = matchRouteDefinitions(routes, '/sub/zero');
/** simplified matches
* [
* {pattern: '/'},
* {pattern: '/sub'},
* {pattern: '/sub/zero'},
* {pattern: '*'},
* ]
* /
So, we can add the *
pattern to the ignore list as well.
const skipPaths = ['/', '*'];
//...
definitions.forEach((definition, index) => {
//...
if (match && !ignoredPaths.includes(definition.path) {
crumbs.push(match);
}
//...
});
const matches = matchRouteDefinitions(routes, '/sub/zero');
/** simplified matches
* [
* {pattern: '/sub'}
* {pattern: '/sub/zero'}
* ]
* /
Edge case 3 - Child route with ''-path with redirect matches parent route
For a use case where a child route has an empty path then the resolved from matchPath
ends up being the same. This may actually be what React Router refers to as an Index
path - but I haven't explored that aspect enough yet.
routes.push({
title: "Another",
path: "/another",
element: <Page title="Another" />,
children: [
{ title: "Another-index", path: "", element: <Page title='Empty' />}
{ title: "Another-other", path: "other", element: <Page title='Other' />}
]
});
const matches = matchRouteDefinitions(routes, '/another/');
/** simplified matches
* [
* {pattern: '/'},
* {pattern: '/another'},
* {pattern: '/another'},
* ]
* /
This means you need a guard or check in place before adding the match.
function getPreviousMatch(previousMatches: PathMatch[]): PathMatch | undefined {
return previousMatches[previousMatches.length - 1];
}
function isNotSameAsPreviousMatch(previousMatches: PathMatch[], match: PathMatch): boolean {
const previousMatchedPathname = getPreviousMatch(previousMatches)?.pattern ?? "";
return previousMatchedPathname !== match.pattern;
}
function isMoreSpecificThanPreviousMatch(previousMatches: PathMatch[], toPathname: string): boolean {
const previousMatchedPathname = getPreviousMatch(previousMatches)?.pathname ?? "";
return toPathname.length > previousMatchedPathname.length;
}
function canBeAddedToMatch(matches: PathMatch[], match: PathMatch) {
return (
isNotSameAsPreviousMatch(matches, match) &&
isMoreSpecificThanPreviousMatch(matches, match.pathname)
);
}
//...
definitions.forEach((definition) => {
//...
if (
match &&
!ignoredPaths.includes(definition.path &&
canBeAddedToMatch(matches, match)
) {
crumbs.push(match);
if (definition.children) {
//...
nestedMatches.forEach((nestedMatch) => {
if(canBeAddedToMatch(matches, nestedMatch)) {
crumbs.push(nestedMatch);
}
});
}
}
//...
});
const matches = matchRouteDefinitions(routes, '/another/');
/** simplified matches
* [
* {pattern: '/'},
* {pattern: '/another'},
* ]
* /
Rendering routes
So, now that we have all our routes defined in a nice object, wouldn't it be good to render them using that same object? As I mentioned in the introduction, this caused me some pain until I realised I could extend the RouteObject
that React Router already exposes. Then it's possible to use the useRoutes
hook to do the rendering for you.
import { routes } from './routes';
export default function App(){
const routesToRender = useRoutes(routes);
return (
<div>
<h1>My App</h1>
{routesToRender}
</div>
)
}
Then in the page that has child routes, including the <Outlet />
component. Remember to do this for each component that has child routes. React Router will then figure out which child routes to render there.
import { Outlet } from "react-router-dom";
export default function Sub() {
const routesToRender = useRoutes(routes);
return (
<div>
<h1>Sub</h1>
<Outlet />
</div>
)
}
Rendering the breadcrumbs
Now that we have all the moving parts in place, we can put it all together in the Breadcrumbs
component. In the below exampled the matchRouteDefinitions
function now returns an ActiveRoutePath
which is a structure that includes both the match
and the RoutePathDefinition
for convenience.
export type ActiveRoutePath = {
title: string;
match: PathMatch<string>
definition: RoutePathDefinition;
};
function useActiveRoutePaths(routes: RoutePathDefinition[]): ActiveRoutePath[] {
const location = useLocation();
const activeRoutePaths: ActiveRoutePath[] = matchRouteDefinitions(routes, location.pathname);
return activeRoutePaths;
}
export function Breadcrumbs({ routes }: BreadcrumbsProps) {
const activeRoutePaths: ActiveRoutePath[] = useActiveRoutePaths(routes);
return (
<>
{activeRoutePaths.map((active, index, { length }) => (
<span key={index}>
{index === 0 ? "" : " > "}
{index !== length - 1 ? (
<Link to={active.match.pathname}>{active.title}</Link>
) : (
<>{active.title}</>
)}
</span>
))}
</>
);
Now, in our App.tsx
we can include the breadcrumbs path and it will render breadcrumbs automatically based on the page you are visiting.
export default function App(){
const routesToRender = useRoutes(routes);
return (
<div>
<div><Breadcrumbs routes={routes} /></div>
<h1>My App</h1>
{routesToRender}
</div>
)
}
Conclusion
In conclusion, matchPath
can be used to manually match a path pattern against the current url to build breadcrumbs for the routes along the path. As a bonus, by extending the RouteObject
type exposed from React Router 6, you can add capabilities specific to your application's needs.
There are two requirements I haven't dug into yet in this post. Stay tuned for the follow-up posts that will cover these cases:
- Be able to show dynamic breadcrumb titles based on parameters.
- Bonus: Support generating Navlinks
I hope you enjoyed this post. Let me know if it's been useful to you, or if you have feedback.
Top comments (0)