DEV Community

Cover image for Let's build a router for React from scratch
Amin
Amin

Posted on

Let's build a router for React from scratch

If you are a React developer, chances are you are using Next.js, a framework for rapidly building and scaling web applications.

Or maybe you are not building enterprise-scale applications and are writting a small React app using React Router.

Either way, you may be familiar with the concept of routing, but do you know how it works?

Today, I'll lead the way in trying to rebuild a pretty trimmed down version of what React Router is offering you as a router mecanism and an excuse to re-invent the wheel yet again and learn more about routing so let's jump on the router train and learn some new things with me as it is my first time doing it!

How?

All I know starting from now is how the React Router API roughly looks like and that we can already add routing to a JavaScript application by using the History API.

Back when I had to write JavaScript apps without frameworks for my school's projects, I use this Web API that helps us using routing inside our JavaScript application, without having to reload the page entirely to change the URL.

It is really a dead simple API: you can change the URL using JavaScript, it does not reload the page and... That's it!

All the logic about refreshing the UI will be our responsibility, but for that we will be using React so that we don't go too far re-inventing the wheel...

If you are not familiar with the History API, I suggest you stop right there and go & learn some things or two about it by reading the official documentation and go back to this article so that we can learn together how to implement our own router component because I'll need you!

Router

I guess we can create first a Route component that will take a path as a prop, and a children that will be the JSX that we want to render. Sounds good?

import { PropsWithChildren, FunctionComponent } from "react";

export interface RouteProps {
  path: string;
}

export const Route: FunctionComponent<PropsWithChildren<RouteProps>> = ({ path, children }) => {
  return null;
};
Enter fullscreen mode Exit fullscreen mode

So far, so good. But we immediately hit a problem: how can I know which route am I in?

Perhaps, the simplest way would be to ask the History API about the current path.

import { PropsWithChildren, FunctionComponent } from "react";

export interface RouteProps {
  path: string;
}

export const Route: FunctionComponent<PropsWithChildren<RouteProps>> = ({ path, children }) => {
  if (path === window.location.pathname) {
    return children;
  }

  return null;
};
Enter fullscreen mode Exit fullscreen mode

This is great, and if we were to test this, it would totally work!

import { Route } from "./components/Route";

export const App = () => {
  return (
    <Route path="/home">
      <h1>Welcome to the home page!</h1>
    </Route>
  );
};
Enter fullscreen mode Exit fullscreen mode

Redirect

Let's try to add a link that will add redirection capabilities to our application.

It will rely on the window.history.pushState method in order to "push" a new page onto the client's browser history. Just like you when you click a link on an e-commerce website.

import { FunctionComponent, MouseEventHandler, PropsWithChildren, useCallback } from "react";

export interface LinkProps {
  to: string;
}

export const Link: FunctionComponent<PropsWithChildren<LinkProps>> = ({ to, children }) => {
  const onClick: MouseEventHandler<HTMLAnchorElement> = useCallback(event => {
    event.preventDefault(); 

    window.history.pushState(to, to, null);
  }, [to]);

  return (
    <a href={to} onClick={onClick}>
      {children}
    </a>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now we can use it to navigate from page to page. Let's try to test it with a new test app.

import { Fragment } from "react";
import { Route } from "./components/Route";
import { Link } from "./components/Link";

export const App = () => {
  return (
    <Fragment>
      <header>
        <ul>
          <li>
            <Link to="/">
              Home
            </Link>
          </li>
          <li>
            <Link to="/about">
              About
            </Link>
          </li>
        </ul>
      </header>
      <main>
        <Route path="/">
          <h1>Welcome to the home page!</h1>
        </Route>
        <Route path="/about">
          <h1>Welcome to the about page!</h1>
        </Route>
      </main>
    </Fragment>
  );
};
Enter fullscreen mode Exit fullscreen mode

Okay, so routing seems to be working on the browser side, but not on the rendered React app. Why?

Because when we rendered the Route component, we didn't add any reactive data, nor reactive prop to react with so we only render it once the page is loaded, and that's it!

If you try to manually route yourself through the search bar of your browser, you'll see that the rendering works correctly. This means we have to make the current route a dynamic data for our routing to work.

Context

Since we need to communicate from the Link component the route that has been clicked to the Route component, we could use a context in order to ease the communication between those components.

Let's do this! We will start by creating a context.

import { createContext } from "react";

export const RouterContext = createContext({
  path: "",
  setPath: (path: string) => {}
});
Enter fullscreen mode Exit fullscreen mode

And then, we will create our provider.

import { FunctionComponent, PropsWithChildren, useMemo, useState } from "react";
import { RouterContext } from "../contexts/RouterContext";

export const RouterProvider: FunctionComponent<PropsWithChildren> =  ({ children }) => {
  const [path, setPath] = useState(window.location.pathname);

  const value = useMemo(() => {
    return {
      path,
      setPath
    };
  }, [path, setPath]);

  return (
    <RouterContext.Provider value={value}>
      {children}
    </RouterContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now we can add this provider to our app.

import { Route } from "./components/Route";
import { Link } from "./components/Link";
import { RouterProvider } from "./providers/RouterProvider";

export const App = () => {
  return (
    <RouterProvider>
      <header>
        <ul>
          <li>
            <Link to="/">
              Home
            </Link>
          </li>
          <li>
            <Link to="/about">
              About
            </Link>
          </li>
        </ul>
      </header>
      <main>
        <Route path="/">
          <h1>Welcome to the home page!</h1>
        </Route>
        <Route path="/about">
          <h1>Welcome to the about page!</h1>
        </Route>
      </main>
    </RouterProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

And we can start adding our new context in our Link component.

import { FunctionComponent, MouseEventHandler, PropsWithChildren, useCallback, useContext } from "react";
import { RouterContext } from "../contexts/RouterContext";

export interface LinkProps {
  to: string;
}

export const Link: FunctionComponent<PropsWithChildren<LinkProps>> = ({ to, children }) => {
  const { setPath } = useContext(RouterContext);

  const onClick: MouseEventHandler<HTMLAnchorElement> = useCallback(event => {
    event.preventDefault(); 

    window.history.pushState(null, to, to);
    setPath(to);
  }, [to, setPath]);

  return (
    <a href={to} onClick={onClick}>
      {children}
    </a>
  );
};
Enter fullscreen mode Exit fullscreen mode

And also in our Route component.

import { PropsWithChildren, FunctionComponent, useContext } from "react";
import { RouterContext } from "../contexts/RouterContext";

export interface RouteProps {
  path: string;
}

export const Route: FunctionComponent<PropsWithChildren<RouteProps>> = ({ path, children }) => {
  const { path: currentPath } = useContext(RouterContext);

  if (path === currentPath) {
    return children;
  }

  return null;
};
Enter fullscreen mode Exit fullscreen mode

Great success! Now it is working well, just like React Router.

The trick was to add a context, so that we can notify our Route component to react to any change to the path variable whenever it has been changed by the Link component.

We kept the window.history.pushState in our Link component because we also need to reflect the path change in our browser, and this API is made for that purpose.

Fallback page

Hmm this one is tricky because there is no way for us to look at what the source-code looks like and we need to figure that out in order to learn and grow.

What about another context? This way, we could create a Fallback component that would be able to know from the parent context that there is no matching route availble and that this component should be rendered.

So let's create a new context, this time only for our routes.

import { FunctionComponent, PropsWithChildren, useContext, useMemo, useState } from "react";
import { RoutesContext } from "../contexts/RoutesContext";
import { RouterContext } from "../contexts/RouterContext";

export const Routes: FunctionComponent<PropsWithChildren> = ({ children }) => {
  const { path } = useContext(RouterContext);
  const [routes, setRoutes] = useState<Array<string>>([]);

  const shouldFallback = useMemo(() => {
    return !routes.some(route => {
      return route === path;
    });
  }, [routes, path]);

  const value = useMemo(() => {
    return {
      routes,
      setRoutes,
      shouldFallback
    };
  }, [routes, setRoutes, shouldFallback]);

  return (
    <RoutesContext.Provider value={value}>
      {children}
    </RoutesContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Let's not forget to update our Route component so that it registers a new route for its parent Routes component.

import { PropsWithChildren, FunctionComponent, useContext, useEffect } from "react";
import { RouterContext } from "../contexts/RouterContext";
import { RoutesContext } from "../contexts/RoutesContext";

export interface RouteProps {
  path: string;
}

export const Route: FunctionComponent<PropsWithChildren<RouteProps>> = ({ path, children }) => {
  const { path: currentPath } = useContext(RouterContext);
  const { setRoutes } = useContext(RoutesContext);

  useEffect(() => {
    setRoutes((routes: Array<string>) => {
      return [
        ...routes,
        path
      ]
    });
  }, [path, setRoutes]);

  if (path === currentPath) {
    return children;
  }

  return null;
};
Enter fullscreen mode Exit fullscreen mode

And now, we can write our Fallback component.

import { FunctionComponent, PropsWithChildren, useContext } from "react";
import { RoutesContext } from "../contexts/RoutesContext";

export const Fallback: FunctionComponent<PropsWithChildren> = ({ children }) => {
  const { shouldFallback } = useContext(RoutesContext);

  if (shouldFallback) {
    return children;
  }

  return null;
};
Enter fullscreen mode Exit fullscreen mode

Let's test this.

import { Route } from "./components/Route";
import { Link } from "./components/Link";
import { RouterProvider } from "./providers/RouterProvider";
import { Fallback } from "./components/Fallback";
import { Routes } from "./components/Routes";

export const App = () => {
  return (
    <RouterProvider>
      <header>
        <ul>
          <li>
            <Link to="/">
              Home
            </Link>
          </li>
          <li>
            <Link to="/about">
              About
            </Link>
          </li>
          <li>
            <Link to="/undefined">
              Undefined
            </Link>
          </li>
        </ul>
      </header>
      <main>
      <Routes>
        <Route path="/">
          <h1>Welcome to the home page!</h1>
        </Route>
        <Route path="/about">
          <h1>Welcome to the about page!</h1>
        </Route>
        <Fallback>
          <h1>Page not found</h1>
        </Fallback>
      </Routes>
      </main>
    </RouterProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Awesome! It works and displays correctly our pages whenever we are on the correct matching page, and the fallback page in case no page matches the one we had in our Route components.

The end, folks!

That is a pretty good job we did there. We have a basic, yet functional routing application and you could already be on your way using the components that we have made together, which are:

  • A RouterProvider component, for sharing the current path between components
  • A Routes component, for helping the Fallback component know when to render
  • A Fallback component
  • A Link component, to update the current route
  • A Route component, to display the current matching path

And that's not even scratching all of the component of the React Router library, this article is not even a drop-in replacement of this wonderful and powerful library!

If you want to dig deeper, you could take a look again at the React Router API and try to enhance this example by adding more component such as nested routing, programmatic routing, search parameters, etc...

I hope that you like reading what you read and that it motivated you in re-inventing the wheel in order to know more about the tools you are using.

Drop a comment if you want to share something awesome with us and see you on the next article!

Top comments (0)