DEV Community

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

Posted on

react-router v6 demystified (part 2)

In my previous article, we have seen what are the new APIs of react-router v6. We also have listed what we expect to develop.
In this article, we won't implement the nested Route and Routes, but don't be afraid it will be done in a next article.

Note: My implementation will not be exactly the same as react-router but it will help you to understand how it works and to explore the react-router repository on your own.

The goal is to be able to implement something like this:

function App() {
  return (
    <Router>
      <Routes>
        <Route path="hobby/" element={<HobbyListPage />} />
        <Route
          path="hobby/:name"
          element={<HobbyDetailPage />}
        />
        <Route path="about" element={<AboutPage />} />
        <Route path="/" element={<HomePage />} />
      </Routes>
    </Router>
  );
}
Enter fullscreen mode Exit fullscreen mode

With a set of utility hooks:

// To get the current location pathanme, query params and anchor
function useLocation();

// To get the path variables
function useParams();

// To push or replace a new url
// Or to go forward and backward
function useNavigate();
Enter fullscreen mode Exit fullscreen mode

Let's start with the Router component


Router component

Implementation

This component is the main one. It will provide the location and methods to change the url, to components below it (in the tree).

react-router provides two router BrowserHistory (using the browser's history) and MemoryHistory (the history will be stored in memory).

In this article, we will only develop a BrowserHistory.

The location and navigation methods will be stored in a React context.
So let's create it and code the provider:

import React from 'react';

const LocationContext = React.createContext();

export default function Router({ children }) {
  return (
    <LocationContext.Provider
      value={{
        // The current location
        location: window.location,
        navigator: {
          // Change url and push entry in the history
          push(to) {
            window.history.pushState(null, null, to);
          },
          // Change url and replace the last entry in the history
          replace(to) {
            window.history.replaceState(null, null, to);
          },
          // Go back to the previous entry in the history
          back() {
            window.history.go(-1);
          },
          // Go forward to the next entry in the history
          forward() {
            window.history.go(1);
          },
          // If we want to go forward or 
          // backward from more than 1 step
          go(step) {
            window.history.go(step);
          }
        }
      }}
    >
      {children}
    </LocationContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

If you try to use these methods to change the url, you will see that it doesn't work.
If you try to play with this code and watch logs, you will see that the component does not render so any component that uses the location will not be informed of the new url.
The solution is to store the location in a state and change it when we navigate through the pages.
But we can't just push the window.location in this state, because in reality the reference of window.location does not change the reference of the object but the object is mutated. So if we do this, it will just do nothing.
So we are going to build our own object, and put the values of pathname, search and hash.

Here is the function to create this new location object:

function getLocation() {
  const { pathname, hash, search } = window.location;

  // We recreate our own object 
  // because window.location is mutated
  return {
    pathname,
    hash,
    search,
  };
}
Enter fullscreen mode Exit fullscreen mode

The creation of the state is:

const [location, setLocation] = useState(getLocation());
Enter fullscreen mode Exit fullscreen mode

Then we just have to change the state when we navigate, for example when we push:

push(to) {
   window.history.pushState(null, null, to);
   setLocation(getLocation());
}
Enter fullscreen mode Exit fullscreen mode

We could do the same for the methods which navigate in the history entries. But it will not work when we go back or forward with the browser buttons.
Fortunately, there is an event that can be listened for this use case. This event popstate is fired when the user navigates into the session history:

useEffect(() => {
  const refreshLocation = () => setLocation(getLocation());

  window.addEventListener("popstate", refreshLocation);

  return () =>
    window.removeEventListener("popstate", refreshLocation);
}, []);
Enter fullscreen mode Exit fullscreen mode

Finally we got the following for our Router:

import React, {
  useContext,
  useEffect,
  useMemo,
  useState,
} from "react";

const LocationContext = React.createContext();

function getLocation() {
  const { pathname, hash, search } = window.location;

  // We recreate our own object
  // because window.location is mutated
  return {
    pathname,
    hash,
    search,
  };
}

export default function Router({ children }) {
  const [location, setLocation] = useState(getLocation());

  useEffect(() => {
    const refreshLocation = () => {
      setLocation(getLocation());
    };

    // Refresh the location, for example when we go back
    // to the previous page
    // Even from the browser's button
    window.addEventListener("popstate", refreshLocation);

    return () =>
      window.removeEventListener(
        "popstate",
        refreshLocation
      );
  }, []);

  const navigator = useMemo(
    () => ({
      push(to) {
        window.history.pushState(null, null, to);
        setLocation(getLocation());
      },
      replace(to) {
        window.history.replaceState(null, null, to);
        setLocation(getLocation());
      },
      back() {
        window.history.go(-1);
      },
      forward() {
        window.history.go(1);
      },
      go(step) {
        window.history.go(step);
      },
    }),
    []
  );

  return (
    <LocationContext.Provider
      value={{
        location,
        navigator,
      }}
    >
      {children}
    </LocationContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note: I memoized the navigator object because we do not want to recreate it each time the location change. But did not memoized the Provider's value because it will be at the top of the React tree and should re-render uniquely when the location changes.


Utility hooks

Now we can implement, some simple hooks which will use this LocationContext. We are going to develop:

  • useLocation: to get the location
  • useNavigator: to get the navigator part

The implementations are the following ones:

useLocation

function useLocation() {
  return useContext(LocationContext).location;
}
Enter fullscreen mode Exit fullscreen mode

useNavigator

function useNavigator() {
  return useContext(LocationContext).navigator;
}
Enter fullscreen mode Exit fullscreen mode

Route component

It's time to continue our implementation with the Route component. The API is simple, it takes:

  • the element to display
  • the path for which this route will be displayed

And the implementation is quite simple:

function Route({ element, path }) {
  return element;
}
Enter fullscreen mode Exit fullscreen mode

As you can see the path prop is not used in this component, but by the Routes component which decides if this Route should be displayed or not.
And this our next part.


Routes component

As I said previously, the Routes component decides which Route to display in function of the location.

Reminder

Because I don't want this article to be too long and difficult. In this part, we are just going to do routing with no nested Route and Routes.

But don't be afraid, in an other article I will code all the features wanted.

Implementation

Now that we know the scope of this article, let's go put our hands in some code.
We know that a Routes takes all the possible Route as children. From this children, we can loop through this children to extract the path of each Route from its props to build a simple array of objects, which easier to process than a React element.

So we want to make a function buildRouteElementsFromChildren that will return an Array of:

type RouteElement = {
  path: string,
  element: ReactNode,
  children: RouteElement[],
}
Enter fullscreen mode Exit fullscreen mode

The code of this function is:

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 || "/",
    };

    routeElements.push(route);
  });

  return routeElements;
}
Enter fullscreen mode Exit fullscreen mode

If we take the following Routes example:

<Routes>
  <Route path="hobby/:name" element={<HobbyDetailPage />} />
  <Route path="hobby" element={<HobbyListPage />} />
  <Route path="about" element={<AboutPage />} />
  <Route path="/" element={<HomePage />} />
</Routes>;
Enter fullscreen mode Exit fullscreen mode

Will be transformed into:

[
  {
    path: "hobby/:name",
    element: <HobbyDetailPage />,
  },
  {
    path: "hobby",
    element: <HobbyListPage />,
  },
  {
    path: "about",
    element: <AboutPage />,
  },
  {
    path: "/",
    element: <HomePage />,
  },
];
Enter fullscreen mode Exit fullscreen mode

Ok, now that we have a simple object, we need to find the first matching Route from this object.

We already now all the possible paths. And thanks to the useLocation, we know the current pathname.

Before doing some code. Let's think about it.

Unfortunately, we can't just compare the current pathname to the Route ones because we have path variables.

Yeah, I guess you already know that we are going to use Regexp :/

For example, if we are at the location /hobby/knitting/ named currentPathname, we want the following path to match:

  • hobby/:name
  • /hobby/:name
  • /hobby/:name/
  • hobby/:name/

For the leading slash we are going to put a slash before the path, and replace all double slash by one:

`/${path}`.replace(/\/\/+/g, "/");
Enter fullscreen mode Exit fullscreen mode

For the trailing slash, we are to put an optional trailing slash in the regex:

new RegExp(`^${regexpPath}\\/?$`);
Enter fullscreen mode Exit fullscreen mode

Now the question is, what is the value of regexpPath. The regex has two objectives:

  • get the path variable name (after the :), here it is name
  • get the value associated to it, here it is knitting
// We replace `:pathVariableName` by `(\\w+)`
// A the same time we get the value `pathVariableName`
// Then we will be able to get `knitting` for
// our example
const regexpPath = routePath.replace(
  /:(\w+)/g,
  (_, value) => {
    pathParams.push(value);

    return "(\\w+)";
  }
);
Enter fullscreen mode Exit fullscreen mode

Now, that we have seen the complexity, let's make some code:

// This is the entry point of the process
function findFirstMatchingRoute(routes, currentPathname) {
  for (const route of routes) {
    const result = matchRoute(route, currentPathname);

    // If we have values, this is the one
    if (result) {
      return result;
    }
  }
  return null;
}

function matchRoute(route, currentPathname) {
  const { path: routePath } = route;

  const pathParams = [];
  // We transform all path variable by regexp to get
  // the corresponding values from the currentPathname
  const regexpPath = routePath.replace(
    /:(\w+)/g,
    (_, value) => {
      pathParams.push(value);

      return "(\\w+)";
    }
  );
  // Maybe the location end by "/" let's include it
  const matcher = new RegExp(`^${regexpPath}\\/?$`);

  const matches = currentPathname.match(matcher);

  // The route doesn't match
  // Let's end this
  if (!matches) {
    return null;
  }

  // First value is the corresponding value,
  // ie: currentPathname
  const matchValues = matches.slice(1);

  return pathParams.reduce(
    (acc, paramName, index) => {
      acc.params[paramName] = matchValues[index];
      return acc;
    },
    {
      params: [],
      element: route.element,
      // We want the real path
      // and not the one with path variables (ex :name)
      path: matches[0],
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

Now that we can get the matching route. We are going to render the Route and use a React context name ReuteContext to put the params.

The Routes component is:

const RouteContext = React.createContext({
  params: {},
  path: "",
});

function Routes({ children }) {
  // Get the current pathname
  const { pathname: currentPathname } = useLocation();
  // Construct an Array of object corresponding to 
  // available Route elements
  const routeElements =
    buildRouteElementsFromChildren(children);

  // 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;
  }

  const { params, element, path } = matchingRoute;

  return (
    <RouteContext.Provider
      value={{
        params,
        path,
      }}
    >
      {element}
    </RouteContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note: You may have noticed the use of a function as second parameter to the replace method. Very useful to get the name of the path variable.

And now we need our hook to get the params:

const useParams = () => useContext(RouteContext).params;
Enter fullscreen mode Exit fullscreen mode

Navigation

Thanks to the useNavigator hook, we can access to methods to navigate between page.
But the development experience is not necessarily the best. For example:

  • Currently, the path is /hobby
  • I push, knitting
  • I would like the new path to be /hobby/knitting

Note: Using the method push of useNavigator, will redirect us to /knitting :(

And:

  • Currently, the path is /hobby/knitting
  • I push, /about
  • I would like the new path to be /about

Note: Currently it works, with the method push of useNavigator.

So, to meet these two needs we are going to develop a hook useResolvePath which returns us the right path, a hook useNavigate and a component Link to navigate where we want easily.

Note: For the navigation we are just going to work with String as path. But in reality, with react-router you can pass object with the following structure:

// For none typescript developers
// The `?` means it's optional
type To = {
  pathname?: string;
  search?: string;
  hash?: string;
} | string;
Enter fullscreen mode Exit fullscreen mode

And in the code we should transform to as object to string and vice versa, but I repeat myself I'm just gonna work with string in this article for simplicity.


useResolvePath

To resume the strategy if the path to resolve is starting with a / then it's an absolute path otherwise a relative path to actual one.

We can get the actual path, thanks to useRouteContext.

Let's implement this:

// Concat the prefix with the suffix
// Then we normalize to remove potential duplicated slash
function resolvePathname(prefixPath, suffixPath) {
  const path = prefixPath + "/" + suffixPath;

  return normalizePath(path);
}

// We export the utility method
// because we need it for other use cases
export function resolvePath(to, currentPathname) {
  // If the to path starts with "/"
  // then it's an absolute path
  // otherwise a relative path
  return resolvePathname(
    to.startsWith("/") ? "/" : currentPathname,
    to
  );
}

export default function useResolvePath(to) {
  const { path: currentPathname } = useRouteContext();

  return resolvePath(to, currentPathname);
}
Enter fullscreen mode Exit fullscreen mode

Then we can develop our useNavigate hook and Link component thanks to that :)


useNavigate

We are going to start with the hook to use it in the component.

This hook will return a callback with the parameters:

  • First parameter: to which is a string (the url to navigate to) or a number if we want to go backward or forward.
  • Second parameter: an object of options. For the article the only option will be replace if the user just want to replace the url (push by default).

Let's make some code:

function useNavigate() {
  const navigator = useNavigator();
  // We want to know the current path
  const { path: currentPath } = useRouteContext();

  // By default it will push into the history
  // But we can chose to replace by passing `replace` option
  // You can pass a number as `to` to go `forward` or `backward`
  return useCallback(
    (to, { replace = false } = {}) => {
      // If to is a number
      // we want to navigate in the history
      if (typeof to === "number") {
        navigator.go(to);
      } else {
        // We want to get the "real" path
        // As a reminder if
        // to starts with / then it's an absolute path
        // otherwise a relative path in relation to currentPath
        const resolvedPath = resolvePath(to, currentPath);
        (replace ? navigator.push : navigator.push)(
          resolvedPath
        );
      }
    },
    [navigator, currentPath]
  );
}
Enter fullscreen mode Exit fullscreen mode

Link

We want to be able to open a new tab from our element, and to have the same behavior than a a tag. So let's use a a with a href property.

But if we just do that, the browser will load the page and refetch assets (css, js, ... files). So we need to prevent this default behavior, we are going to put an onClick method and preventDefault the event.

function Link({ to, children, replace = false }) {
  const navigate = useNavigate();
  // We want to get the href path
  // to put it on the href attribtue of a tag
  // In the real inplementation there is a dedicated hook
  // that use the `useResolvePath` hook
  // and transform the result into string
  // (because potentially an object but not in this article)
  const hrefPath = useResolvePath(to);

  // We put the href to be able to open in a new tab
  return (
    <a
      href={hrefPath}
      onClick={(event) => {
        // We want do not browser to "reload" the page
        event.preventDefault();
        // Let's navigate to `to` path
        navigate(to, { replace });
      }}
    >
      {children}
    </a>
  );
}
Enter fullscreen mode Exit fullscreen mode

And here we go, we can navigate to new pages.

Note: I also developed a NavLink which is useful for navigation item, when you want to display which item is active. You can see the code on the codesandbox below.


Playground

Here is a little code sandbox of this second part of react-router implementation:

Conclusion

In this article, we have coded the base to make a react-router like library. The main goal is to understand how works the main routing library for React, in its next version 6.

To resume what we have learned and done in this second article about react-router v6:

  • The Router provides the location and methods to navigate through pages.
  • The Route corresponding to a specific page / path
  • The Routes component determines the Route to display, and provides the current pathname of the Route and the params.

Let's meet in my next article which will implement nested Route and Routes, and also bonus hooks.

If you want to see more about react-router v6 which is in beta yet, let's go see the migration guide from v5.


Want to see more ? Follow me on Twitter or go to my Website. 🐼

Discussion (0)