React Universal Routing

How to design application routing for server and client

To render or not to render (server side)

In recent years, the JavaScript world has gone through a huge revolution from implementing simple dynamic elements in static sites to creating large, engaging applications that live right in our browsers. This significant change was possible thanks to the libraries which made Single Page Application (SPA) development possible on a much bigger scale than before. One of the top libraries is React, which comes with many auxiliary libraries and utilities to make SPA development a breeze.

Of course, there are many advantages of SPAs over classic static approach for both users and developers. However, we have to mention some big drawbacks that the whole community is trying to address. Two main issues that are easy to perceive are potential Search Engine Optimization (SEO) disadvantage and time to first paint in a user’s browser. Those two come together because the classic SPA concept assumes an empty initial HTML and dynamic initialization of content after fetching JS files. This initial lack of content makes some robots (and users) angry and affects our site visibility and ultimate satisfaction.

All in all, we agree that SPA brought new quality into frontend design and user experience, but to address disadvantages and make a step further we were introduced to an upgraded idea — server-side rendered (SSR) Single Page Application, which combines classic server prepared content with a better user experience of SPA. Why is SSR so important? Let’s say that it gives us the fastest possible first print of the app, which then can be put into life step by step or all at once by fetched JS files. It also provides more reliable visibility for SEO-heavy projects, because even some search vendors may index classic SPA properly, but there is always a disadvantage comparing it to server-side rendered content which it is synchronous, stateless, and lays at the root of HTTP.

How to structure SSR/SPA routing — our goals

Right now we know that a SPA is excellent and SSR makes it even better, but even the whole idea is quite simple, and we have right tools, there are many different ways to deal with some challenges along the way. One of the critical components of the SPA is routing, which should specify what data we fetch and how do we present them. Those two aspects of routing are crucial for every frontend application.

First of all, our code should be efficient so both client and server implementations won’t put us into trouble when the application grows. Our implementation should put logic into proper order and do not engage any unnecessary computation. Moreover, we should try to create one universal code that works well in both environments. That kind of solution gives us many advantages concerning lesser complexity and better consistency of application logic.

These are concrete goals that make the creation of new elements and incorporating them into our application very easy and undemanding. Thus, today’s question is this: “How should we design application routing to make it plain and simple for both server and client sides?”.

Classic SPA — fetching data client-side

First off, let’s recap what would be the most natural way to provide our components with data. Assume that we have some REST API — endpoints providing data. We can render component, and in its lifecycle method (usually after mount), we can call the action that fetches data and saves it in a global Store or component’s State, whatever we prefer.

There are different opinions whether we should retrieve data in every component that needs it or fetch it in some view component, that obtains everything required by its children. Whatever we choose, fetching data in components is a natural and straightforward way, and it works like a charm especially for smaller projects.

When we decide to fetch data in components the only thing left is to use presentation routing, which is a mapping that translates requested pathname into view (component) we should display. That could be easily achieved using ReactRouter or any other implementation that allows us to create routing based on components structure. Then each component may fetch required data for itself or for its children components, which is exactly how many classic SPA projects operate.

SSR application — fetching data server-side

Is it possible to get data server-side using component-based fetching? The thing is that getting it all would require to wait for asynchronous actions and then render components in their final state. When we imagine that every component in the component tree could fetch something at any point in time, we see that it is impossible to find a single event to wait for, after which we could start final rendering. We can try different approaches restricting our component’s behavior, e.g., fetching only right after mount or prepare particular actions, static methods or other functions only eligible to get data. Even though that may work, it hugely reduces our freedom in fetching data from within components.

Another thing, concerning efficiency, is that we should render component tree only once to make sure that we spend time wisely preparing a response and not wasting time trying to simulate a browser environment. The conclusion is quite straightforward — our data should be ready before even touching the component tree.

Trying to fetch data client way on a server environment is a dead end so let’s get back to the basics of server-side rendering. We have many examples available starting from simple libraries and ending with holistic frameworks that were designed to provide HTTP response with a fully rendered page. The solutions are all over the programming world, available in many different languages. The common thing those solutions share is the sequence of handling routing, fetching data and sending the whole page back to the browser. This sequence is the synchronous logic that awaits asynchronous bits. It starts with analyzing routing — mapping the pathname. Then it is responsible for preparing all required data, some requesting from the database and other computed. Then prepared data is given to a response template that fills every variable and every gap with provided data. Finally, the browser receives HTML as a result of this process.

Application flow (black arrows) and implementation (green arrows)

Server implementation of double routing

Server approach is pretty straightforward, and many programmers are familiar with it, so let’s try to put some React into the equation and find out what we end up with. We use ReactRouter and Redux libraries to help us with the implementation. The idea is simple. At first, we apply routing that defines the requested path (view). Then according to this path, we should trigger actions and fetch everything the view needs. At last, we collect the data and provide them to our component tree, render it to string and prepare the final response.

To make that happen and maintain flexibility, we need two routings, so below is an example of server implementation. Please pay attention there are two route-based actions: first — data fetching, second — component rendering. Our logic behaves as every efficient server ought to behave.

import * as express from "express";

import * as React from "react";

import * as ReactDOM from "react-dom/server";

import {createStore, applyMiddleware} from "redux";

import {Provider} from "react-redux";

import thunk from "redux-thunk";

import {StaticRouter} from "react-router"; import reducer from "./reducer";

import appPathDataFetcher from "./data-fetcher";

import AppLayout from "./components/AppLayout";

import renderFullHtml from "./utils/render-full-html"; const app = express();

app.use(routeServerApp);

app.listen(3000); async function routeServerApp(req, res) {



// prepare store before fetching data

const store = createStore(reducer, applyMiddleware(thunk));



// fetch data logic - first routing

const currentRoute = {pathname: req.path, query: req.query};

await appPathDataFetcher(store.dispatch, currentRoute, null);



// render components with provided data - second routing

const componentHtml = ReactDOM.renderToString(

<Provider store={store}>

<StaticRouter location={req.url}>

<AppLayout/>

</StaticRouter>

</Provider>

); // prepare and send HTTP response with HTML text

const initialState = store.getState();

const html = renderFullHtml(componentHtml, initialState);

res.setHeader("Content-Type", "text/html");

res.status(200).send(html);

}

In this code, two imports need special explanation, both regarding our double-routing approach. The first implementation is appPathDataFetcher which is asynchronous action that fetches data for a specific route (path). The second part is inside AppLayout which contains presentation routing. We shall see the implementation of those two later. For now, it is crucial to understand that they are universal so we can use them here and in the following client example.

Client implementation of double routing

We know that fetching data in components won’t work on a server, besides, let’s think for a second why do we even bother to bound component rendering with fetching data. This approach may bring some issues, especially when we consider a large application where different components fetch and use the same data. At the beginning of this article, we stated that component-based fetching is a simple and obvious approach, but when we aim for universal code, we should consider separating data fetching from components, just like the server does and present two independent routings that instead of being coupled together, may complement one another. Maybe a server-based solution can work on a client well enough and won’t be less natural for us.

The goal is to use previously prepared routing logic: first that fetches all required data for a certain view, and second that renders the view using provided data. This way we separate data logic from presentation logic, which is crucial for our solution. Let’s start with client implementation of presentation logic.

import * as React from "react";

import * as ReactDOM from "react-dom";

import {createStore, applyMiddleware} from "redux";

import {Provider} from "react-redux";

import thunk from "redux-thunk";

import {BrowserRouter} from "react-router-dom"; import reducer from "./reducer";

import AppLayout from "./components/AppLayout"; const store = createStore(reducer, applyMiddleware(thunk)); // we hydrate already rendered server-side content

ReactDOM.hydrate(

<Provider store={store}>

<BrowserRouter>

<AppLayout/>

</BrowserRouter>

</Provider>,

document.getElementById("root")

);

AppLayout which contains presentation routing is the same component we used in our server implementation. That is great because whenever we need to make a change to the main presentation logic, we have to do it only in one place. Please note that we use ReactRouter here and we wrap AppLayout in BrowserRouter because this is client implementation, we used StaticRouter in server implementation accordingly.

Now it is time to use our data-fetching logic in a client environment.

import * as React from "react";

import {connect} from "react-redux";

import {withRouter} from "react-router";

import * as qs from "query-string";

import * as hoistStatics from "hoist-non-react-statics"; import appPathDataFetcher from "./data-fetcher"; export const routeHandler = InnerComponent => { class RouteHandlerC extends React.Component { async componentDidUpdate(prevProps) {

const {location, dispatch} = this.props;

if (prevProps.location.key !== location.key) {

const currentRoute = {

pathname: location.pathname,

query: qs.parse(location.search)

};

const prevRoute = {

pathname: prevProps.location.pathname,

query: qs.parse(prevProps.location.search)

};

await appPathDataFetcher(

dispatch,

currentRoute,

prevRoute

);

}

} render() {

return <InnerComponent {...this.props} />;

}

} const RouteHandler = withRouter(connect()(RouteHandlerC)); // opinionated HOC return - copy all non-React static methods

return hoistStatics(RouteHandler, InnerComponent);

};

A decorator function routeHandler watches every change in routing and fetches data accordingly using appPathDataFetcher (same as the server). Passing current and previous routes helps to provide context for actions. The decorator should wrap the main application component, so in our case, we use it along the definition of AppLayout .

Now it is time to present implementation of our two elements that fit so perfectly for client and server.

Universal presentation logic

Component-based routing renders proper view according to a current pathname. We only take care of a component that should be displayed, because data fetching is an independent logic. This approach gives us the flexibility to define different presentations for the same data or on the other hand use one generic presentation for different data routes.

Server-side is static and works the same for each request, so it always fetches data once and renders once. On the contrary client-side has to be prepared for changing routes, fetching data multiple times and rendering them accordingly. Because of this, we need routeHandler decorator to wrap our highest application component and fetch data on every route change. This logic affects absolutely nothing on a server (so you may skip it) but is a must-have for a client.

Here is simplified routing that renders different views.

import * as React from "react";

import {Route, Switch} from "react-router"; import {routeHandler} from "./route-handler";

import {Homepage, ArticleList, ArticleDetail} from "./components"; const componentRoutes = [

{path: "/", component: Homepage},

{path: "/articles/, component: ArticleList},

{path: "/articles/:articleId(\\d+)/", component: ArticleDetail}

]; // we decorate main component with independent data-fetching logic

// routeHandler is crucial for client and has no effect for server export const AppLayout = routeHandler(() => {

return (

<Switch>

{componentRoutes.map(route => <Route

key={route.path}

path={route.path}

component={route.component}

exact={route.exact !== false}

strict

/>)}

</Switch>

);

});

Universal data logic

Let’s get back to appPathDataFetcher which is the core when it comes to retrieving data for our application. The most important thing for data-fetcher is fetchRoutes — definition map that defines which actions to trigger according to a given route. This map is quite similar to what we have in presentation routing with a difference that here we look for actions to call and not components to render.

Another critical difference is that component-based routing is flat in a single component render, but we are allowed to embed other routings in components deep down the tree. As a result, we may end up with many independent routings which is an excellent thing for presentation logic. Fetching logic, on the other hand, should have only one source of truth and that’s why our structure should be nested so we can easily fetch global application data as well as local data for the specific view.

This is how our example fetchRoutes may look like.

import {fetchGlobalAppData} from "./app-actions";

import {fetchHomepageData} from "./homepage-actions";

import {fetchArticleList, fetchArticleDetail} from "./article-actions";

import {mapActions} from "./util-actions" export const fetchRoutes = [

{

path: "/", exact: false,

fetch: fetchGlobalAppData,

routes: [

{

path: "/",

fetch: fetchHomepageData

},

{

path: "/articles/",

fetch: fetchArticleList

},

{

path: "/articles/:articleId(\\d+)/",

fetch: fetchArticleDetail

}

]

}

];

appPathDataFetcher is universal, asynchronous logic that maps route paths into actions we need to call. The specific implementation is not important for our case, because you may implement it on your own and even change route map definition according to your needs. However, for the sake of example completeness, we have the suggested implementation below.

import {matchPath} from "react-router";

import {fetchRoutes} from "./fetch-routes"; // main data-fetcher logic based on `fetchRoutes`

export async function appPathDataFetcher(

dispatch, currentRoute, prevRoute

) {

const matches = matchFetches(fetchRoutes, current.pathname);

let idx = 0;

let result = true;

while (result && idx < matches.length) {

const match = matches[idx++];

const matchRoute = {...current, params: match.params};



// each action has to be dispatched

result = await dispatch(match.fetch(

matchRoute, prevRoute, result

));

}

} // finds array of matches, searching deep into routing tree

function matchFetches = (routes, pathname) {

let matches = [];

for (const route of routes) {

const match = matchPath(pathname, {

exact: route.exact !== false,

strict: true,

path: route.path

});

if (match == null) { // path does not match

continue;

}

// update matches with matched params and actions

const current = {params: match.params, fetch: route.fetch};

const deeper = route.routes ?

matchFetches(route.routes, pathname) : [];

matches = [current, ...deeper];

break;

}

return matches;

}

Conclusion

When we think about it, fetching data and rendering view have nothing in common, except higher controller logic that orchestrates their behavior. Because of this, the idea of double routing and separation of concerns may be quite beneficial. Providing logic that does only one thing but does it well should certainly improve readability and should be helpful for different members of a team to cooperate. When we work on bigger projects with our team the important factor is easiness to reason about application logic.

How to structure application routing? This question always has many different and well-suited answers, but when we create more and more frontend applications, and we move them to the server environment, we usually need to think twice and consider new requirements that arise before us. In the end, frontend in its current state is quite young, and standard solutions are not always well designed. We should choose wisely, evaluate opinionated proposals from different languages and adopt them into our code.