Managing React SPA Navigation

5 minute read

React Single Page Applications (SPAs) are great! But they do create a problem when you refresh a page on a path that’s different from the originating page or when you link directly to such a path. Let’s talk about why that is and how we can fix it.

Kubernetes

Image by TimArbaev on iStock by Getty Images

The Problem With Single Page Applications (SPAs)

Single Page Applications or SPAs, as the name would suggest, are web applications that are really just a single page. A JavaScript framework (e.g., React) is used to change the presentation and perform different actions based on the context in which the page is being used. React SPAs are great in that they can be developed by breaking the different elements down into reusable components, which are tied together to form an application that behaves like a multi-page application. Only, SPAs are more responsive that a traditional website composed of different HTML files served up by making independent requests to a Web server. That is because SPAs rely on asynchronous operations and in-memory navigation.

Herein lies the problem with SPAs. In a traditional website, when you navigate to /, the web server serves up a file associated with /. Similarly, when you navigate to /foo, the web server serves up a different file, now associated with /foo. However, with an SPA, / and /foo are rendered from the same file (usually the one located at /). To make things even more interesting, /foo may depend on in-memory state that was created by /. So, navigating directly to /foo without originating at / can result in undesirable behavior (like an error or the page not rendering).

React Router Basics

React Router provides an in-memory navigation facade that allows navigation between page components to look like navigation between different web pages.

The basic setup is as follows.

index.js

import React from "react";
import { render } from "react-dom";
import {Route, Switch, BrowserRouter as Router} from "react-router-dom";

render(
      <Router>
        <Switch>
          <Route exact path='/' component={RootPage} />
          <Route exact path='/foo' component={FooPage} />
          <Route component={PageNotFound} />
        </Switch>
      </Router>,
    document.getElementById("root")
);

In the above configuration, index.js is the JavaScript entry point, which defines the available in-memory routes. The RootPage component displays when a user first navigates to the SPA. When the user clicks on a special navigation link to /foo, the path in the address bar changes to reflect /foo and the FooPage component displays. If a user clicks on a special navigation link of anything other than / or /foo then the PageNotFound component displays.

At this point, if a user navigates directly to /foo or if she refreshes the page after having navigated to /foo from /, a 404 error will be returned. This is because the navigation provided by React Router is in-memory; the Web server doesn’t know about it. However, when a user navigates directly to a page or if she refreshes a page, that triggers an HTTP request, which is handled by the Web server.

Configuring The Web Server for an SPA

In order to avoid the dreaded 404 error when a user navigates directly to a route defined by React Router, the Web server needs to be somewhat SPA aware. It doesn’t have to be super smart, but it does have to be smart enough to know to delegate navigation to the SPA. Basically, we want the Web server to forward all requests to the root page where our SPA resides so that the SPA displays. We also want the path in the browser’s address bar to reflect the route for the SPA to service. This effectively delegates navigation to the SPA.

Here is such a configuration for Nginx.

server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    root /usr/share/nginx/html;

    location / {
        # <-- this bit here is what's added for push state support
        if (!-e $request_filename) {
          rewrite ^(.*)$ / break;
        }
        #  -->

        index index.html index.htm Default.htm;

    }
}

Dissecting the above Nginx configuration, we can see the Web server is listening on port 80, the standard unencrypted http port. The real magic, however happens with the following configuration.

root /usr/share/nginx/html;

and

    location / {
        # <-- this bit here is what's added for push state support
        if (!-e $request_filename) {
          rewrite ^(.*)$ / break;
        }
        #  -->

        index index.html index.htm Default.htm;

    }

The root /usr/share/nginx/html; specifies the root where the Web server should serve files (HTML, JavaScript, images, etc.). The configuration that starts with location / { specifies that any request under the / should serve the root page, which is where the SPA resides. Additionally, since this only deals with how the Web server responds to requests, the url in the browser will be whatever is requested from the user’s perspective. In other words, it’s not a redirect; it’s more like a pointer.

What About State?

As mentioned above, sometimes state loaded by one component is needed by another component. In the case where those two components are in the same hierarchy, it’s no problem: the component that loads the state passes some or all of that state to the second component. In that case, if the page in question is reloaded or navigated to directly then all is well.

However, in the case where a component is dependent upon state that is loaded by an adjacent component (e.g. page components) then there must be some higher top level state to bind them together. This can be done through bubbling everything up to a top level state that cascades its state down, or it can be done through something like Redux.

Redux is outside the scope of this post. But, it is worth mentioning some peculiarities when it comes to React state. One of the first things React developers learn when working with React is that state changes trigger a component re-render. To put it more simply, if a component’s state changes then that component will be re-rendered. That, however, does not hold true for state changes that are not directly tied to rendering the component.

Let’s take an example where the root page component / loads and displays user data and the /foo page component loads and displays transaction data based on the selected user. Now, let’s say while on the /foo page, the user data state changes. If a component on the /foo page isn’t directly tied to that changed user data state, /foo will not be re-rendered.

This is called a transitive dependency. The transactions displayed on the /foo page are dependent upon the user data state. But since the user data state is not directly tied to the rendering of transactions, changes to the user data leave the page rendered with stale data. Or perhaps worse, what if someone navigated directly to /foo, the page might error or fail to render at all because the user data isn’t present in its state, which is needed to get the transaction data that is tied to the rendering of the component(s) on the page.

The solution to that particular problem, however, will have to wait for a future post on Redux.