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)