DEV Community

Cover image for react-router v6 demystified (part 3)
Romain Trotard
Romain Trotard

Posted on • Edited on • Originally published at romaintrotard.com

react-router v6 demystified (part 3)

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 />,
  },
]
Enter fullscreen mode Exit fullscreen mode

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:

Branches creation process

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 />,
    },
  ],
]
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

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;
}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

And now we pass the parentPath to the findFirstMatchingRoute method:

const matchingRoute = findFirstMatchingRoute(
  routeElements,
  currentPathname,
  parentPath
);
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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 += "$";
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
andreiisakov1 profile image
Andrei • Edited

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?

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <BrowserRouter>
      <ErrorBoundary FallbackComponent={CustomError}>
        <App />
      </ErrorBoundary>
    </BrowserRouter>
  </React.StrictMode>
)
Enter fullscreen mode Exit fullscreen mode

and the app:

export const App = () => {
  if (isDevEnv()) {
    const navigate = useNavigate();
    const restoreOriginalUri = async (originalUri: string) => {
      navigate(toRelativeUrl(originalUri || '/', window.location.origin));
    };
    return (
      <Security restoreOriginalUri={restoreOriginalUri}>
        <AppRoutes />
      </Security>
    )
  }
  return (
    <AppRoutes />
  )
}
Enter fullscreen mode Exit fullscreen mode

Does it mean, that the App component is the exception and has the LocationContext?

Thanks!

Collapse
 
romaintrotard profile image
Romain Trotard

Hello Andrei.

Thanks for your comment :)

Actually, LocationContext is accessible under the Router component.
i.e. In DOM environment under the BrowserRouter component.

That's why in your example, the usage of useNavigate will work inside your App component.

If you check my implementation, useNavigate uses:

  • useNavigator that uses the navigator implementation of Router which is the main implem to navigate your user
  • useRouteContext that uses the RouteContext. In your case there is no context up in the tree, but no pb because there is a default value for the context:
const RouteContext = React.createContext({ params: {}, path: "" });
Enter fullscreen mode Exit fullscreen mode

This path is "only" useful for relative path.

I hope it helps :D

Collapse
 
andreiisakov1 profile image
Andrei

Thanks once more! Now my understanding of the react hooks improved :)
Nice articles!