Recently, I was inspired by a tweet by Joel Califa to attempt to build a simple router in React. The goal was to end up with three things:

  1. A way to render different "page" components depending on the location's path
  2. A way to link between those pages without having to pass state around
  3. Use no dependencies (other than React, of course)

My first instinct was that I could just use existing browser history event APIs. As long as I have a PageLink component that calls history.pushState(path) when clicked, there must be some event that a router context can listen in on when that happens. Unfortunately, this isn't the case! The browser provides a popstate event, but that's only called as a result of user action (such as clicking the back button) or some other history APIs. We will end up using popstate, eventually, though, so stay tuned, popstate fans!

The Setup

First, let's set up the basic application skeleton. We'll have an App component that renders a different page, depending on the current route. Sketched out roughly, it looks something like this:

import Home from "./pages/Home";
import About from "./pages/About";
import NotFound from "./pages/NotFound";

export default function App() {
  // Hand-wavy "get the current route state"
  const route = getRoute();

  switch (route) {
    case "/":
      return <Home />;
    case "/about":
      return <About />;
    default:
      return <NotFound />;
  }
}

Pretty simple! We get the route (somehow) and render the appropriate page based on that route.

We also need some way of linking between these pages. For that, we'll create a PageLink component:

export default function PageLink(props) {
  return (
    <a
      href={props.path}
      onClick={(evt) => {
        evt.preventDefault();
        setRoute(props.path); // Hand-wavy
      }}
    >
      {props.children}
    </a>
  );
}

This component just renders its children inside of a link. When clicked, we'll have to come up with some way for it to set the route to the path property. Note that we want to avoid passing state around, so we're going to rule out anything like props.setRoute. In addition, we want to be able to put these page components in separate files, so relying on route and setRoute being in scope and using closures is out of the picture, as well.

Context

Thankfully, React has a very useful tool for when we want to pass data through the component tree without having to pass props down manually at every level. It's called Context, and its documentation states that it "provides a way to pass data through the component tree without having to pass props down manually at every level"!

In order to make use of React Context, we're going to build a component called a Provider. Essentially, the Provider component is what will allow Consumer components, which we'll build later, to consume (and update) the routing state! First we'll create our Context object and our Provider component:

// Just create an empty context, we'll give it data later.
export const RouterContext = React.createContext();

/* Technically, we could just put this in our `App` component, but I like doing it this way, where only this simple wrapper component has the raw state value and its setter in it. */
export function RouterProvider(props) {
  // The initial state for the Provider will always be the current location.
  const [route, setRoute] = useState(location.pathname);

  return (
    <RouterContext.Provider
      // Set the initial state: A `route` value, and a `setRoute` function
      value={{
        route: route,
        setRoute: (path) => {
          history.pushState(null, "", path);
          setRoute(path);
        },
      }}
    >
      {props.children}
    </RouterContext.Provider>
  );
}

This RouterProvider component uses RouterContext.Provider component in the context object we created. It encapsulates one piece of state: the route itself, which we'll set up soon. In addition to the route property, the component also provides a setRoute function. Note, however, that we're not just blindly passing the setRoute function created by useState(location.pathname). This would work for state-management purposes, but we also want to ensure that setting the route also updates the current URL! In order to do that, the setRoute function in the provider calls the browser's history API and "pushes" the new route into the history stack via history.pushState(null, '', path). After that, then it updates its route state by calling the original setRoute function provided by useState.

Wiring it Up

Now that we have the provider set up, we'll use it (and the consumer) in our application component to get the current route for rendering the proper page component:

+ import { RouterProvider, RouterContext } from "./router";
  import Home from "./pages/Home";
  import About from "./pages/About";
  import NotFound from "./pages/NotFound";
  
  export default function App() {
    /* We want to render the provider once at the highest necessary level for consuming it */
    return (
+     <RouterProvider>
+       {/* We can use the consumer now that the provider is setup */}
+       <RouterContext.Consumer>
+         {({ route }) => {
            switch (route) {
              case "/":
                return <Home />;
              case "/about":
                return <About />;
              default:
                return <NotFound />;
            }
+         }}
+       </RouterContext.Consumer>
+     </RouterProvider>
    );
  }