Routing in React with Universal Router

ndrean

NDREAN

Posted on July 14, 2022

Routing in React with Universal Router

A quick showcase of UniversalRouter (1.6kB zip) as an "easy" alternative to React Router.

What is it? The code used for the router is not embedded within React components. It is pure Javascript code that allows executing code on a given path, and then delivers React components to React for rendering. It uses the browser navigation and the History interface.

What is the point of using this? An example: when you navigate to a page, you may want to render data. Instead of using a useEffect in the component, you can pre-fetch the data and then pass it as an argument to a stateless React component, all this asynchronously (and no double rendering).
Lastly, the code is pretty stable :)

In this showcase, we don't use redirects, just a "nabvar" component with links that stays on top of each page and renders components as children.

Map of "links"

A navbar is a collection of links. Each link has a path and title attribute. We define a map of objects that contain these attributes:

export const useLinks = [
  { path: "/", title: "Home" },
  { path: "/users", title: "pre-render" },
  { path: "/vusers", title: "Valtio store" },
  [...]
];
Enter fullscreen mode Exit fullscreen mode

Each object of this array will be the arguments of a "link" element.

const Link = ({ path, title, handler }) => 
  <a href={path} onClick={handler}>
    {title}
  </a>
Enter fullscreen mode Exit fullscreen mode

The onClick handler is defined in the parent component "Navbar". If any extra code needs to be executed for a given path, we can define it in our route array, as seen further down.

The Navbar

We build the Navbar component by iterating over the map of <Link /> objects.
The "onClick" handler will simply push the found pathname attribute into the browser history session. The Navbar will render any component as a child.

const NavBar = ({ children }) => {
  function handleNav(e) {
    e.preventDefault();
    history.push({ pathname: e.target.pathname });
  }

  return (
    <>
      {useLinks.map(({ path, title }) => (
        <Link key={title} path={path} title={title} handler={handleNav} />
      ))}
      {children}
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

The routes

The routing is made by defining a route object which is an array of path objects. A path object is defined with three reserved keys: path, action and children. A path object has the form:

{path: "/component", action: handler, children: []}
Enter fullscreen mode Exit fullscreen mode

The optional children array will give nested routes.

An action is simply a function. It will eventually return a React component (in our case as we have no redirects). Every action can be made async, in particular, we can make dynamic imports.

For example, suppose we want to navigate to a page which displays data retrieved from an api. The action can be:

async function PreFetch() {
  const users = await fetchComments(8);
  const { default: Users } = await import("../utils/users");
  return <Users data={users} />;
}
Enter fullscreen mode Exit fullscreen mode

and our path object is:

{ path: "/users", action: async () => PreFetch() }
Enter fullscreen mode Exit fullscreen mode

It admits an object context that can be used by the path objects. The action accepts the context object from the routes as an attribute. We can use this to pass a data store for example (we showcased a Valtio data store here) so that we don't need to spread the store through the code. Just inject it into the component through the routes. Easy!
The context object also captures the "params" if needed.

An example of a route array that UniversalRouter will transverse:

const routes = [
  {
    // wrapping the routes with the Navbar and render every component as a child
    path: "",
    action: async ({ next }) => {
      const component = await next();
      const { default: NavBar} = await import('./NavBar')
      return component && <NavBar>{component}</NavBar>
    },
    children: [
      {
        path: "/",
        action: async () =>
          import(".Home").then(({ Home }) => <Home />)
      },
      {
        path: "/users",
        action: async () => PreFetch()
      },
      {
        path: "/vusers",
        async action({ vStore }) {
          await vStore.getUsers(2);
          const { default: Users } = await import("../utils/users");
          return <Users data={vStore.users} />;
        }
      },

      {
        path: "(.*)",
        action: () => <img scr="404.webp" ...</h1>
      }
    ]
  }
];
Enter fullscreen mode Exit fullscreen mode
const Users = ({ data }) => (
    <>{data && data.map((user) => <User key={..}... />)}</>
  );
Enter fullscreen mode Exit fullscreen mode

Route parsing and rendering

import { createBrowserHistory } from "history";
export default createBrowserHistory();
Enter fullscreen mode Exit fullscreen mode
  • We initiate our router object and can pass in some context whenever an action might need it. For example, we use a data store (called vStore here) managed by Valtio:
// example of Valtio store
import { proxy, useSnapshot } from "valtio";
import { fetchUsers } from "./fetchUsers";
export { useSnapshot };

export const vStore = proxy({
  users: null,
  async getUsers(id) {
    vStore.users = await fetchUsers(id);
  },
});
Enter fullscreen mode Exit fullscreen mode

We can pass it to the context key in the constructor and any path object action method can use this store with action(context) {...} whenever needed.

const router = new UniversalRouter(routes, {context: {vStore}});
Enter fullscreen mode Exit fullscreen mode

How does this work?

The history listens to paths change and triggers a renderRoute function. UniversalRouter transverses the "routes" array used in the constructor to find a match with the path. It then executes the action which will return a React component (in our case). It then calls the React.render function on the returned function.

import { createRoot } from "react-dom/client";
import React from "react";

import UniversalRouter from "universal-router";
import history from "./router/history";

import routes from "./router/routes";

import { vStore } from "./valtio/vStore";


const context = { vStore };

const router = new UniversalRouter(routes, { context });

const root = createRoot(document.getElementById("root"));

async function renderRoute(location) {
  try {
    // "history" returns a path, and "router" finds a match in the routes array
    const page = await router.resolve({
      pathname: location.pathname
    });

    return root.render(<>{page}</>);
  } catch (err) {
    console.log(err);
    return root.render(<p>Wrong!</p>);
  }
}

history.listen(({ location }) => renderRoute(location));
history.push("/");
Enter fullscreen mode Exit fullscreen mode

A word about redirection

Just return a path that returns an object {redirect: "/path_you_want"}. With the middleware approach and our Navbar, we modify the path:

path: "",
    action: async ({ next }) => {
      const component = await next();
      const { default: NavBar} = await import('./NavBar')
      if (component.redirect) return component
      return component && <NavBar>{component}</NavBar>
    },
Enter fullscreen mode Exit fullscreen mode

Since "component" will contain the key "redirect", it remains to let the resolve function return the path in the "main.js". This works thanks to the key "redirect":

const page = await router.resolve({
  pathname: location.pathname
});
if (page.redirect) {
  return history.push(page.redirect);
}
return root.render(<>{page}</>);
Enter fullscreen mode Exit fullscreen mode
πŸ’– πŸ’ͺ πŸ™… 🚩
ndrean
NDREAN

Posted on July 14, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related