Routing in React, a different approach

Routing in React, a different approach

Recently, I've been unsatisfied with current routing solutions in Javascript, especially when coupled with React. Don't get me wrong, libraries out there are great, like React-Router, but they don't well overlap with my idea of routing. Sometimes they're too coupled with the view system, other they miss some basic features or are not customizable enough. My idea, of course, has been to write yet another router library but this time it isn't something I started from scratch. It happened that long time ago (more than 7 years ago!) I wrote a router library. It was a time when the history.pushState API wasn't introduced yet and routing involved hashbang, !#/ at the end of the regular url. Inside that library there were some good ideas and it has been used a lot, so it is a good starting point.


tl;dr

If you aren't interested in reasons behind the code or if you prefer to get your hands dirty, go checkout these libraries:

RouterJS: VanillaJS routing library.
React-RouterJS: The way to use the library with React.

View agnostic

My idea is that the library should be "view agnostic". I'd say more, it must be framework agnostic. It should be possible to use it with vanilla JS, React, Vue, outside of the browser or anywhere else you like. Let's focus on the meaning of being view agnostic. I feel like a routing library is really unrelated from the fact that we use it to show a particular view: we will do it eventually, in most of the cases, but showing a view is a side effect of the routing. Routing is the ability to map a state (usually the current url) with another state. Actually the pushState API does the same: it lets you define a state while navigating with the browser but tells nothing about what should be shown on the screen. This is what I missed so much using react-router: it is so coupled with the view, the react components, that is hard to use it for other use cases. It's not impossible, it's just unnatural for me.

So let's try to stay simple and say that a route is a function bound to an url match. This is what happens in express.js or zeit-micro. Here how to do it with RouterJS:

import { createRouter } from 'routerjs';

const options = {/*...*/};
const router = createRouter(options)
	.get('/user/:name', userRoute)
	.get('/post/:slug', postRoute)
simple routes definition

The take is easy: define a route through a string (in this case there are also named parameters like :name) and bind a function to it. This is the simplest pattern we can have for a routing library and in fact it's very, very common.

Where is defined which view to show? Nowhere.
We're free to setup our own mechanism to select a view inside those functions or use a more generic approach: for example we can create a React library that accepts the route definition and somehow show a View, but we'll see this later or, if you're curious, read the "React integration" section.

Multiple engines

Another idea is that, while the core concept is the same, "a route matches a function", the way these two pieces are glued together can depend on many factors: is the pushState API available? Do we want to use hashbang in our app? Are we on something different from a browser, for example on some javascript framework for mobile applications?

What changes, in all of these cases, is the way we navigate, the way we understand the route is changed and so on. For this reason the router accepts an engine. The default is an engine based on browser history but we can pass our own implementation.

import { createRouter } from 'routerjs';

// The default, an engine using popState API
const router = createRouter({engine: BrowserHistoryEngine()});

// An engine for ReactNative
const router = createRouter({engine: ReactNativeEngine()});

// An engine for hashbangs
const router = createRouter({engine: HashBangEngine()});

While just the first engine has a real implementation and maybe the other makes no sense at all (I'm not very practical of react native coding :p), you have more freedom now and you can implement your own engine with peculiar features. An engine is something very trivial: a function that returns an object with various methods, to get the current route or to execute redirects and so on.

Middlewares

Middleware is a handy concept: basically the idea is to provide a set of components (middleware) that run before your main function. The most known implementation of middlewares is the one of express but I think the most powerful one comes from zeit-micro: a middleware is a function. Nothing special, just a function. It has no metadata associated, no special class derivation, no factory. Nothing, just a function. So in the frontend router of my dreams a middleware is a function. A similar approach is taken by page.js by visionmedia. An additional feature I need to have is that an error should stop normal flow.

Now, let's see the most classical middleware example: authorization. The idea is to forbid a route if the user is not authenticated. First of all lets see how a route function is done:

import { createRouter } from 'routerjs';

const protectedRoute = (req) => {
  console.log(`Hello, I'm a protected route!`);
};

createRouter
  .get('/protected', protectedRoute);
A typical, simple route

Now let's implement a middleware that checks if the user is authenticated

cont authMiddleware = fn => async (req) => {
    // this fake async call returns true if the user is authenticated.
    // The implementation is not shown because it's not important, 
    // you can imagine whatever you want: a fetch request or 
    // a cookie check
    if(!await isAuthenticated(req)) {
       throw new Error('User not authenticated');
    }
    return fn(req);
}
A simple middleware, a function

So, a middleware is a function that takes the next function as parameter (which can be another middleware) and applies some logic. Let's apply this to our protected route

import { createRouter } from 'routerjs';
import authMiddleware from './authMiddleware';

const protectedRoute = (req) => {
  console.log(`Hello, I'm a protected route!`);
};

router = createRouter()
 .get('/protected', authMiddleware(protectedRoute)));

If you need to apply more middlewares you just need to wrap one in the other, or use a typical compose function, provided by the library.

import { createRouter, compose } from 'routerjs';
import authMiddleware from './authMiddleware';
import logMiddleware from './logMiddleware';

const protectedRoute = (req) => {
  console.log(`Hello, I'm a protected route!`);
};

router = createRouter()
 .get('/protected', compose(
    authMiddleware,
    logMiddleware,
    // ... other middlewares
  )(protectedRoute));
Middleware composition

Easy, isn't it?

Other goodies

There's not enough space in this post to explain what else is available in the library, so you should have a  look at the official documentation, you will find:

  • Request and context
  • Error handling
  • Always executed callbacks
  • Auto-click handler
  • Base-path definition

React integration

Let's talk about the integration with React. Even if I talked a lot about how the router library shouldn't be tied to the view system, this doesn't mean they should not interact. It's very powerful, in my opinion, to have them separated, the same way redux can work without React even if they're often coupled. The integration is left to another little module called react-routerjs. Let's see how it works.

First, it provides you a RouterProvider, similar to the one from redux or similar libraries. It accepts your routing definition:

import { createRouter } from 'routerjs';
import { RouterProvider } from 'react-routerjs';

const router = createRouter() //...your routing definition

const App = () => {
  return (<RouterProvider router={router}>
      // ... your app components
  </Router>);
}

This should be placed as on top as possible in your components hierarchy. It's really nothing fancy, just a context provider.

This give you access to a series of components. One of this is a Link component. While RouterJS doesn't need any special component for the links, you may have defined your routes with a basePath because your application is served under an /app path for example. So, any time you want an anchor, you should write something like

<a href="/app/user">Profile</a>

The Link component can read the router configuration and let you write

<Link href="/user">Profile</Link>

instead, taking care of transforming the url in the correct, expanded path.

The main usage of the React binding is the ability to select a view component. Let's see it in action:

import React form 'react';
import { createRouter, compose } from 'routerjs';
import { withView } from 'react-routerjs';

const Home = React.lazy(async() => await import('./Home'));
const Profile = React.lazy(async() => await import('./Profile'));

const router = Router()
  .get('/home', compose(
      // ...other middlewares
      // We can tell that this route is shown through the Home component
      // that can be provided directly or, as in this case, through a
      // React.lazy call
      withView((req, ctx) => <Home />)
  )(homeRoutehandler))
  
  .get('/profile/:id', compose(
      withView((req, ctx) => <Profile id={req.params.id} />)
  )(profileRoutehandler))

Later we can use the <RouteView> component to show it in the application

import {RouterProvider, RouteView} from 'react-routerjs';

const App = () => {
  return <RouterProvider>
      // ...
      <RouteView />
  </RouterProvider>
}

The <RouteView /> component will use the Home or the Profile components to show the correct view. Since we can return a promise through React.lazy, we'll take advantage of React Suspense to lazy load our components 😍

The RouteView and withView couple can also specify a target to set the current view for different parts of the application.

That's not all folks

If you liked what you read, discover the libraries on Github, there's much more!

RouterJS: VanillaJS routing library.
React-RouterJS: React components for RouterJS.