In the previous article, we have seen how to implement a react-router
v6 lib like. But we have not implemented the nested Route
and Routes
. We are going to do this major features in this article.
Outlet
Before going deep into nested Route
, we need to talk about a new component. The Outlet
represents the nested Route
of the current one.
For example in the example:
<Route path="hobby">
<Route path="/" element={<HobbyListPage />} />
<Route path=":name" element={<HobbyDetailPage />} />
</Route>
The Outlet
of <Route path="hobby">
will be in function of the url:
-
<HobbyListPage />
when on/hobby
-
<HobbyDetailPage />
when on/hobby/:name
Note: Each routes are wrapped with their own
RouteContext
How is it stored?
Yeah you may ask: "How is this done?"
Actually it's quite easy the outlet
is stored in the RouteContext
.
Implementation
The implementation of the Outlet
component is:
function Outlet() {
// Get the outlet from the current `RouteContext`
const { outlet } = useRouteContext();
return outlet;
}
Small change in Route
As you may notice we want to be able to do <Route path="hobby">
. Yep, there is no element. So in this case we want the element to be by default Outlet
:
// Path just usefull for Routes
function Route({ path, element = <Outlet /> }) {
return element;
}
And here we go, we are ready to do some nested Route
:)
Nested Route
In this part let's implement the ability to do:
<Routes>
<Route path="hobby">
<Route path="/" element={<HobbyListPage />} />
<Route path=":name" element={<HobbyDetailPage />} />
</Route>
<Route path="about" element={<AboutPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
As a reminder, we transform the React element into simple javascript objects, in a buildRouteElementsFromChildren
method.
We will have to handle in this method, the potential children that can have a Route
element.
function buildRouteElementsFromChildren(children) {
const routeElements = [];
// We loop on children elements to extract the `path`
// And make a simple array of { elenent, path }
React.Children.forEach(children, (routeElement) => {
// Not a valid React element, let's go next
if (!React.isValidElement(routeElement)) {
return;
}
const route = {
// We need to keep the route to maybe display it later
element: routeElement,
// Let's get the path from the route props
// If there is no path, we consider it's "/"
path: routeElement.props.path || "/",
};
// If the `Route` has children it means it has nested `Route`
if (routeElement.props.children) {
// Let's transform the children `Route`s into objects
// with some recursivity
let childrenRoutes = buildRouteElementsFromChildren(
routeElement.props.children
);
// It could happen that it was only
// non valid React elements
if (childrenRoutes.length > 0) {
// Notify that this route has children
route.children = childrenRoutes;
}
}
routeElements.push(route);
});
return routeElements;
}
So the previous example will become:
[
{
path: "hobby",
// It's the default element
element: <Outlet />,
children: [
{
path: "/",
element: <HobbyListPage />,
},
{
path: ":name",
element: <HobbyDetailPage />,
},
],
},
{
path: "about",
element: <AboutPage />,
},
{
path: "/",
element: <HomePage />,
},
]
Ok, now that we have a simple object, we need to list all the possible paths that we will be named branches.
Let's see the process with this gif:
The final branches are:
[
[
{
path: "hobby",
element: <Outlet />,
},
{
path: "/",
element: <HobbyListPage />,
},
],
[
{
path: "hobby",
element: <Outlet />,
},
{
path: ":name",
element: <HobbyDetailPage />,
},
],
[
{
path: "hobby",
element: <Outlet />,
},
],
[
{
path: "about",
element: <AboutPage />,
},
],
[
{
path: "/",
element: <HomePage />,
},
],
]
Not too complicated, isn't it?
Let's make some code:
function createBranches(routes, parentRoutes = []) {
const branches = [];
routes.forEach((route) => {
const routes = parentRoutes.concat(route);
// If the `Route` has children, it means
// it has nested `Route`s
// So let's process them by recursively call
// `createBranches` with them
// We need to pass the current path and the parentRoutes
if (route.children) {
branches.push(
...createBranches(route.children, routes)
);
}
branches.push(routes);
});
return branches;
}
And now we have to find the matching branch. The idea is the same than in the 2nd article but now we will loop on routes that can be in a branch.
The process will be:
- Loop on branches
- We instantiate a variable
pathname
with the current one (it will be changed) - In the branch, let's loop on routes:
- Build regexp from the root path (if it's the last route, do not forget to end with
$
) - If the location matches the regexp and it's not the last route we remove the matching pathname from the current one to test it with the next route.
- If it isn't the last route let's do the same thing with the next branch
- If it was the last route and it has matched we found the right branch. Let's return it. Otherwise let's process the next branch.
- Build regexp from the root path (if it's the last route, do not forget to end with
And here is the corresponding code:
// routes variable corresponds to a branch
function matchRoute(routes, currentPathname) {
// Ensure that the path is ending with a /
// This is done for easy check
currentPathname = normalizePath(currentPathname + "/");
let matchedPathname = "/";
let matchedParams = {};
const matchesRoutes = [];
for (let i = 0; i < routes.length; i++) {
const route = routes[i];
const isLastRoute = i === routes.length - 1;
const routePath = route.path;
const currentParamsName = [];
const regexpPath = routePath
// Ensure there is a leading /
.replace(/^\/*/, "/")
.replace(/:(\w+)/g, (_, value) => {
currentParamsName.push(value);
return "(\\w+)";
});
// Maybe the location end by "/" let's include it
const regexpValue = `^${regexpPath}\\/?${
isLastRoute ? "$" : ""
}`;
const matcher = new RegExp(regexpValue);
const pathNameTocheck = normalizePath(
`${
matchedPathname === "/"
? currentPathname
: currentPathname.slice(matchedPathname.length)
}/`
);
const matches = pathNameTocheck.match(matcher);
// The route doesn't match
// Let's end this
if (!matches) {
return null;
}
const [matchingPathname, ...matchValues] = matches;
matchedPathname = joinPaths(
matchedPathname,
matchingPathname
);
const currentParams = currentParamsName.reduce(
(acc, paramName, index) => {
acc[paramName] = matchValues[index];
return acc;
},
{}
);
matchedParams = { ...matchedParams, ...currentParams };
matchesRoutes.push({
params: matchedParams,
route,
path: matchedPathname,
});
}
return matchesRoutes;
}
Now that we have found the matching branch, we need to display it. As you may have seen the parent Route is the first element of the branch so we need to reduceRight
to pass second as outlet of previous element.
function Routes({ children }) {
// Construct an Array of object corresponding to
// available Route elements
const routeElements =
buildRouteElementsFromChildren(children);
// Get the current pathname
const { pathname: currentPathname } = useLocation();
// We want to normalize the pahts
// They need to start by a "/""
normalizePathOfRouteElements(routeElements);
// A Routes component can only have one matching Route
const matchingRoute = findFirstMatchingRoute(
routeElements,
currentPathname
);
// No matching, let's show nothing
if (!matchingRoute) {
return null;
}
return matchingRoute.reduceRight(
(outlet, { route, path, params }) => {
return (
<RouteContext.Provider
value={{
outlet,
params,
path,
}}
>
{route.element}
</RouteContext.Provider>
);
},
null
);
}
And that's it we have a working implementation of nested Route
.
Let's now see how to implement nested Routes
.
Nested Routes
Before seeing an example of what we would like to be able to code:
function App() {
return (
<Router>
<Routes>
<Route path="about/*" element={<AboutPage />} />
</Routes>
</Router>
);
}
function AboutPage() {
// Here you will find a nested `Routes`
return (
<Routes>
<Route
path="extra"
element={<p>An extra element made with a Routes</p>}
/>
<Route
path="/"
element={
<Link to="extra" className="link">
Show extra information
</Link>
}
/>
</Routes>
);
}
Note: You may have notices the trailing
/*
which indicates that it should match all path
In the Routes
component, we can get the parent pathname with its params, thanks to the RouteContext
:
const { params: parentParams, path: parentPath } =
useContext(RouteContext);
And now we pass the parentPath
to the findFirstMatchingRoute
method:
const matchingRoute = findFirstMatchingRoute(
routeElements,
currentPathname,
parentPath
);
And when we put the path and params in the Context we just have to concat with the parents ones:
return matchingRoute.reduceRight(
(outlet, { route, path, params }) => {
return (
<RouteContext.Provider
value={{
outlet,
// We want to have the current params
// and the parent's too
params: { ...parentParams, ...params },
path: joinPaths(parentPath, path),
}}
>
{route.element}
</RouteContext.Provider>
);
},
null
);
The final code of Routes
is then:
function Routes({ children }) {
// Construct an Array of object corresponding to available Route elements
const routeElements =
buildRouteElementsFromChildren(children);
// Get the current pathname
const { pathname: currentPathname } = useLocation();
// Get potential Routes parent pathname
const { params: parentParams, path: parentPath } =
useContext(RouteContext);
// We want to normalize the pahts
// They need to start by a "/""
normalizePathOfRouteElements(routeElements);
// A Routes component can only have one matching Route
const matchingRoute = findFirstMatchingRoute(
routeElements,
currentPathname,
parentPath
);
// No matching, let's show nothing
if (!matchingRoute) {
return null;
}
return matchingRoute.reduceRight(
(outlet, { route, path, params }) => {
return (
<RouteContext.Provider
value={{
outlet,
// We want to have the current params and the parent's too
params: { ...parentParams, ...params },
path: joinPaths(parentPath, path),
}}
>
{route.element}
</RouteContext.Provider>
);
},
null
);
}
Okay it looks good, but what is the magic of findFirstMatchingRoute
?
findFirstMatchingRoute
final implementation
In the method, we just going to remove of the currentPathname
the parent's one.
function findFirstMatchingRoute(
routes,
currentPathname,
parentPath
) {
const branches = createBranches(routes);
// We remove the parentPath of the current pathname
currentPathname = currentPathname.slice(
parentPath.length
);
for (const branch of branches) {
const result = matchRoute(branch, currentPathname);
if (result) {
return result;
}
}
return null;
}
You have probably figure it out that the real magix is in the matchRoute
function.
matchRoute
implementation
The changes done in the method concern the construction of the regexpPath
.
The major thing to understand is that when the Route path is ending with a *
with are going to add (.*)
to the regex to match everything after the wanted pathname.
But doing this naively will break the value of the matching pathname. For example:
// If we have the Route path: 'hobby/:name/*'
// And the current pathname is: '/hobby/knitting/photos'
// In this case the matching pathname will be:
const matchingPathname = '/hobby/knitting/photos';
// But we would like to have
const matchingPathname = '/hobby/knitting';
So we are going to make a group by wrapping with parentheses before adding (.*)
.
The construction of the regex is now:
const regexpPath =
"(" +
routePath
// Ensure there is a leading /
.replace(/^\/*/, "/")
// We do not want to keep ending / or /*
.replace(/\/?\*?$/, "")
.replace(/:(\w+)/g, (_, value) => {
currentParamsName.push(value);
return "(\\w+)";
}) +
")";
// Maybe the location end by "/" let's include it
let regexpValue = `^${regexpPath}\\/?`;
if (routePath.endsWith("*")) {
regexpValue += "(.*)";
currentParamsName.push("*");
}
if (isLastRoute) {
regexpValue += "$";
}
And we now get the matching pathname at the second position of the matches array:
// With the grouping the matching pathname is now
// at the second poistiong (indice 1)
const [_, matchingPathname, ...matchValues] = matches;
And here we go! We have an implementation of the nested Routes
that works :)
Playground
Here is a little code sandbox of this third part of react-router
implementation:
Conclusion
In this third article we ended with a major feature which is to be able to do nestes Route
and Routes
. And a working react-router
implementation like.
Note, that this implementation is not perfect, you will have to make sure to put the path in the right order. For example if you put the Route
with the path /
, it will match EVERYTHING. In the real implementation, they coded a weight system to reorder Route
from the more restricted path to the less one.
I hope you enjoyed the articles and you now have a better idea of how the react-router
v6 is implemented :)
Do not hesitate to comment and if you want to see more, you can follow me on Twitter or go to my Website. 🐼
Top comments (3)
Hello Romain!
Thanks a lot for the articles!
Do I understand correctly that the LocationContext is accessible only from within Routes component?
If yes, then how the code below is working?
and the app:
Does it mean, that the App component is the exception and has the LocationContext?
Thanks!
Hello Andrei.
Thanks for your comment :)
Actually,
LocationContext
is accessible under theRouter
component.i.e. In DOM environment under the
BrowserRouter
component.That's why in your example, the usage of
useNavigate
will work inside yourApp
component.If you check my implementation,
useNavigate
uses:useNavigator
that uses thenavigator
implementation ofRouter
which is the main implem to navigate your useruseRouteContext
that uses theRouteContext
. In your case there is no context up in the tree, but no pb because there is a default value for the context:This
path
is "only" useful for relative path.I hope it helps :D
Thanks once more! Now my understanding of the react hooks improved :)
Nice articles!