Aurélien Martin

Vanilla React Part 2: Routing

Published on March 8, 2023

Introduction #

Welcome to the second part of this series. In the first part, we have seen how to setup a basic React application rendered on the server. In this part, we will see how to setup routing in our application. Our routing system will support:

Here, we won't use react-router, but we will implement our own routing system. We will demonstrate that it is not that hard to implement a basic routing system in React and that you don't need to depend on a third party library.

Most third party libraries are great, but they can also be a source of complexity and add an overhead to your bundle. If you don't need a complex routing system, you can save yourself some time and effort by implementing your own.

Frontend routing #

Routing is the process of determining how an application responds to a client request to a particular endpoint, which is a URI (or path) and a specific HTTP request method (GET, POST, and so on). For static websites, routing is usually handled by the web server, but for single-page applications, routing is typically handled by the client-side JavaScript.

Our frontend application should render different components depending on the current URL. For example, if the user is on the / path, we should render the Home component, if the user is on the /posts path, we should render the Posts component, and so on.

Our client side source from Part 1 actually look like this:

import React from "react";
import ReactDOM from "react-dom";

const e = React.createElement;

function App() {
  return e("div", null, "Hello world!");
}

if (typeof window !== "undefined") {
  ReactDOM.hydrate(App(), document.getElementById("root"));
}

export default App;

We can handle routing using the window.location.pathname property which contains the current path on our browser:

function App() {
  switch (window.location.pathname) {
    case "/":
      return e("div", null, "Home");

    case "/posts":
      return e("div", null, "Posts");
  }
}

If you try to use this code, you will see that the application is not working anymore. This is because the window object is not available on the server side:

ReferenceError: window is not defined

Server-side routing #

Our ReferenceError come from this line in our server.js:

const serverSideRender = ReactDOMServer.renderToString(App());

We are calling the App function to render our application on the server side. But this function is using the window.location.pathname property, which is not available on the server side.

To fix this, we can pass the current path, from the ctx.request object provided by koa to the App function:

const props = {
  location: ctx.request.url,
};

const serverSideRender = ReactDOMServer.renderToString(App(props));

Same thing on the client side:

if (typeof window !== "undefined") {
  const props = {
    location: window.location.pathname,
  };
  ReactDOM.hydrate(App(props), document.getElementById("root"));
}

And then, we can use this path in our App function:

function App(props) {
  switch (props.location) {
    case "/":
      return e("div", null, "Home");

    case "/posts":
      return e("div", null, "Posts");
  }
}

Great! Now, our application is working again. You can try to go to the /posts path and see that the Posts message is rendered.

Components organization #

We can split our App into smaller components:

function Home() {
  return e("div", null, "Home");
}

function Posts() {
  return e("div", null, "Posts");
}

function Router(props) {
  switch (props.location) {
    case "/":
      return e(Home);

    case "/posts":
      return e(Posts);
  }
}

function App(props) {
  return e(Router, props);
}

How can we handle dynamic navigation? For example, if we want to navigate from the Home component to the Posts component, how can we do that?

First, we need to add a link to the Home component:

function Home() {
  return e(
    "div",
    null,
    e("p", null, "Home page"),
    e("a", { href: "/posts" }, "Go to posts")
  );
}

If you try to click on the link, you will see that the page is reloaded. This is because the link is a regular HTML link, and the browser is reloading the page when we click on it. We need to prevent the default behavior of the link and change the location used by our App component.

For now, the location is coming from the window.location.pathname property our from our server. We need to store this location in a state variable, and update it when the user click on a link:

function Router(props) {
  const [location, setLocation] = React.useState(props.location);

  switch (location) {
    case "/":
      return e(Home, { setLocation });

    case "/posts":
      return e(Posts, { setLocation });
  }
}

And use this setLocation function in our Home component:

function Home(props) {
  return e(
    "div",
    null,
    e("p", null, "Home page"),
    e(
      "a",
      {
        href: "/posts",
        onClick: (e) => {
          props.setLocation("/posts");
        },
      },
      "Go to posts"
    )
  );
}

Now if you click on the link, the page is not reloaded anymore, and the Posts component is rendered.

However, if you try to reload the page, you will see that the Home component is rendered again. This is because the location is not stored in the browser URL. We need to use the browser window.history.pushState function to update the URL. We define a navigate function that will update the location state variable and push a new state in the browser history:

function Router(props) {
  const [location, setLocation] = React.useState(props.location);

  const navigate = (path) => {
    setLocation(path);
    window.history.pushState({}, "", path);
  };

  switch (location) {
    case "/":
      return e(Home, { navigate });

    case "/posts":
      return e(Posts, { navigate });
  }
}

And use that function in our Home component:

function Home(props) {
  return e(
    "div",
    null,
    e("p", null, "Home page"),
    e(
      "a",
      {
        href: "/posts",
        onClick: (e) => {
          e.preventDefault();
          props.navigate("/posts");
        },
      },
      "Go to posts"
    )
  );
}

Great, we almost have a working navigation. But if you try to click on the browser back button, you will see that the page is not updated.

The browser expose a popstate event that is triggered when the user click on the back or forward button. We can use this event to update the location state variable:

function Router(props) {
  const [location, setLocation] = React.useState(props.location);

  const navigate = (path) => {
    setLocation(path);
    window.history.pushState({}, "", path);
  };

  React.useEffect(() => {
    const onPopState = () => {
      setLocation(window.location.pathname);
    };

    window.addEventListener("popstate", onPopState);

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

  switch (location) {
    case "/":
      return e(Home, { navigate });

    case "/posts":
      return e(Posts, { navigate });
  }
}

Here, we are using the useEffect hook to avoid adding the event listener on every render. We are also using the useEffect return function to remove the event listener when the component is unmounted. You can read more about the useEffect hook here.

Now, if you click on the back button, the page is updated.

We have a fully working router and navigation. You can try to add more routes and see that everything is working as expected.

Route parameters #

How can we handle dynamic routes? For example, if we want to display a post based on its ID, how can we do that?

We can use simple regex to extract those parameters from URLs. For example, if we want to extract the post ID from the URL /posts/123, we can use the following regex:

const match = "/posts/123".match(/\/posts\/(\d+)/);
const postId = match && match[1];

Each route, will define a match function that will return the route parameters if the URL matches the route:

const routes = [
  {
    path: "/",
    match: (path) => path === "/",
    component: Home,
  },
  {
    path: "/posts",
    match: (path) => path === "/posts",
    component: Posts,
  },
  {
    path: "/posts/:id",
    match: (path) => {
      const match = path.match(/\/posts\/(\d+)/);
      return match && { id: match[1] };
    },
    component: ShowPost,
  },
];

In our router, we now need to loop over the routes to find the matching route:

function Router(props) {
  // ..
  for (const route of routes) {
    const params = route.match(location);
    if (params) {
      const Component = route.component;
      return e(Component, { navigate, params });
    }
  }

  return e("div", null, "Not found");
}

Your ShowPost component can now use the id parameter:

function ShowPost(props) {
  return e("div", null, `Post ${props.params.id}`);
}

Great, we have a fully working router with dynamic routes.

All of that, with less than 100 lines of code!

Conclusion #

In this article, we have seen how to build a simple router and navigation system with React. Our router is not as powerful as the one provided by React Router, but can be sufficient in many cases. You can freely extend it to add more features.