It was surprising to me to discover the absence of an adequate example of a React application with nested routes, automatically generated navigation, and breadcrumbs. All of the examples I could find require copypasting code to some extent. I'll try to fill in this gap and create an application satisfying the following criteria:
- routing with
react-router-dom
- configurable nested routes
- automatically generated navigation and breadcrumbs
- DRY
The working example is available on GitHub: https://github.com/sneas/react-nested-routes-example
Routes
The most obvious way to build routes is to directly put them into the markup:
<Router>
<Route path="/about">
<About />
</Route>
<Route path="/users">
<Users />
</Route>
<Route path="/">
<Home />
</Route>
</Router>
It's also possible to store routes in an array and render them in a loop.
const routes = [
{
path: "/about",
component: About
},
{
path: "/users",
component: Users
},
{
path: "/",
component: Home
}
];
return (
<Router>
{routes.map(route => (
<Route path={route.path} component={route.component} />
))}
</Router>
);
Let's take this into consideration to build a router with a nested structure.
const routes = [
{
path: "/",
component: Home,
routes: [
{
path: "/about",
component: About,
routes: [
{
path: "/about/our-team",
component: OurTeam
}
]
},
{
path: "/users",
component: Users
},
]
}
];
Now we need to loop through the nested structure to output all the routes. This can be achieved by flattening our tree structure.
const flattenRoutes = routes =>
routes
.map(route => [route.routes ? flattenRoutes(route.routes) : [], route])
.flat(Infinity);
const routes = [
// Same as in previous snippet
];
return (
<Router>
{flattenRoutes(routes).map(route => (
<Route path={route.path} component={route.component} />
))}
</Router>
);
It's worth noting that flattenRoutes
puts more specific routes closer to the beginning of the array:
/about/our-team
/about
/users
/
This will help us to use the parent route as a fallback when a child route can't be found. For example, opening /about/non-existing-page
will end up routing user to /about
component.
Now let's DRY things up a little bit and automatically generate prefix for each individual route based on its parent. Instead of "/about/our-teams"
we will only need to store "/our-teams"
.
const combinePaths = (parent, child) =>
`${parent.replace(/\/$/, "")}/${child.replace(/^\//, "")}`;
const buildPaths = (navigation, parentPath = "") =>
navigation.map(route => {
const path = combinePaths(parentPath, route.path);
return {
...route,
path,
...(route.routes && { routes: buildPaths(route.routes, path) })
};
});
const routes = [
{
path: "/",
component: Home,
routes: [
{
path: "/about",
component: About,
routes: [
{
path: "/our-team",
component: OurTeam
}
]
},
{
path: "/users",
component: Users
},
]
}
];
const flattenRoutes = routes =>
routes
.map(route => [route.routes ? flattenRoutes(route.routes) : [], route])
.flat(Infinity);
return (
<Router>
{flattenRoutes(buildPaths(routes)).map(route => (
<Route path={route.path} component={route.component} />
))}
</Router>
);
Nested Menu
Let's create a nested menu for each page. In order for the nested menu to be visible on every page, we can create a single Page
container. The Page
container will hold the menu, breadcrumbs, and page contents.
const Page = ({ route }) => {
// Let's output only page contents for now and
// take care of the menu and breadcrumbs later
const PageBody = route.component;
return <PageBody />;
};
return (
<Router>
{flattenRoutes(buildPaths(routes)).map(route => (
{routes.map(route => (
<Route key={route.path} path={route.path}>
<Page route={route} />
</Route>
))}
))}
</Router>
);
Page
container receives the current route
prop. This prop will be used to build a nested menu and breadcrumbs.
The nested menu for a particular page consists of menus of its parents up to the root. To build a nested menu for a particular page, each route must know its parent.
const setupParents = (routes, parentRoute = null) =>
routes.map(route => {
const withParent = {
...route,
...(parentRoute && { parent: parentRoute })
};
return {
...withParent,
...(withParent.routes && {
routes: setupParents(withParent.routes, withParent)
})
};
});
// ...
return (
<Router>
{flattenRoutes(setupParents(buildPaths(routes))).map(route => (
{routes.map(route => (
<Route key={route.path} path={route.path}>
<Page route={route} />
</Route>
))}
))}
</Router>
);
After parents of each page have been set, they can be used to our advantage of building nested menus.
const Menu = ({ routes }) => (
<nav className="menu">
{routes.map((route, index) => (
<NavLink key={index} to={route.path}>
{route.label}
</NavLink>
))}
</nav>
);
const pathTo = route => {
if (!route.parent) {
return [route];
}
return [...pathTo(route.parent), route];
};
const NestedMenu = ({ route }) => (
<>
{pathTo(route)
.filter(r => r.routes)
.map((r, index) => (
<Menu key={index} routes={r.routes} />
))}
</>
);
const Page = ({ route }) => {
const PageBody = route.component;
return (
<>
<NestedMenu route={route} />
<PageBody />
</>
);
};
We've created 2 components: NestedMenu
and Menu
. The NestedMenu
component specializes in rendering the entire nested menu for a particular route. It loops through the list of parent routes from the root to the specified route
. The list is provided by pathTo(route)
function. The navigation for an individual route is rendered by Menu
component.
Breadcrumbs
For the breadcrumbs, we can use a similar approach as we used to create the nested menu.
const Breadcrumbs = ({ route }) => (
<nav className="breadcrumbs">
{pathTo(route).map((crumb, index, breadcrumbs) => (
<div key={index} className="item">
{index < breadcrumbs.length - 1 && (
<NavLink to={crumb.path}>{crumb.label}</NavLink>
)}
{index === breadcrumbs.length - 1 && crumb.label}
</div>
))}
</nav>
);
const Page = ({ route }) => {
const PageBody = route.component;
return (
<>
<NestedMenu route={route} />
{route.parent && <Breadcrumbs route={route} />}
<PageBody />
</>
);
};
The Breadcrumb
component also loops through the list of routes provided by the previously described pathTo(route)
function. It makes sure the "current" route to be rendered as a text and parent routes to be rendered as a link:
{index < breadcrumbs.length - 1 && (
<NavLink to={crumb.path}>{crumb.label}</NavLink>
)}
{index === breadcrumbs.length - 1 && crumb.label}
We don't want to render breadcrumbs for the root route. The root route could be determined by the absence of parents: {route.parent && <Breadcrumbs route={route} />}
.
Conclusion
The provided solution satisfies all the previously defined criteria:
- the app uses
react-router-dom
- the nested routes are configured as a tree-like structure
- the navigation and breadcrumbs are rendered automatically based on the configuration
- the app code and configuration does not repeat itself
Top comments (0)