Async routing with React Lazy, Suspense and React Router

2021-01-107 min

Today's article is about creation of asynchronous routing in React application. What I am going to show you is really simple application containing few basic views, which will be imported asynchronously as separated chunks during navigating through the application via React Router.

Application overview

app-overview.jpg

Libraries

Within this project I am using:

  • "react": "^17.0.1",
  • "react-dom": "^17.0.1",
  • "react-router": "^5.2.0",
  • "react-router-dom": "^5.2.0",

Implementation

First of all I started react project using Create React App, then I installed

  • "react-router": "^5.2.0",
  • "react-router-dom": "^5.2.0",
  • "@types/react-router": "^5.1.8",
  • "@types/react-router-dom": "^5.1.6",

When we are ready with packages, we can clean up our codebase by removing unnecessary files, and images from CRA, then I suggest to create a following structure for article purpose:

src
  /components
  /containers
  /views

Next step would be creation of three simple views components which are Home, About and Contact. You can do it however you want, I just created very basic components for article purposes

import React from 'react';

const Home: React.FC = () => (
  <div>Homepage</div>
);

export default Home;

About and Contact components made in the same way.

Routing implementation

We have views components created, so this is a time for Routing implementation. Let's try with defining normal routing without lazy loading first.

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

import About from '../../views/About/About.component';
import Contact from '../../views/Contact/Contact.component';
import Home from '../../views/Home/Home.component';

export const AppRouter: React.FC = ({ children }) => (
<Router>
  { children }
  <Switch>
    <Route exact path='/' component={Home}/>
    <Route exact path='/contact' component={Contact}/>
    <Route path="/about" component={About}/>
  </Switch>
</Router>
);

As you can see our component is a simple router. I am going to render Navigation as a children property. Let's create Nav component.

import React from 'react';
import { Link } from 'react-router-dom';

export const Nav: React.FC = () => (
  <ul>
    <li>
      <Link to={'./'}>Home</Link>
    </li>
    <li>
      <Link to={'./about'}>About</Link>
    </li>
    <li>
      <Link to={'./contact'}>Contact</Link>
    </li>
  </ul>
);

Last step to have working routing is adding it to App component

import React from 'react';
import { AppRouter } from './containers/Router/Router.component';
import { Nav } from './containers/Nav/Nav.component';

const App = () => (
  <div className='app'>
    <AppRouter>
      <Nav />
    </AppRouter>
  </div>
);

export default App;

Cool! We have a working Router. But we still have a single bundle.

single-chunk.png

As you can see on the image, even if we go through whole app, we have a single chunk.

Async routing implementation

Now is a time to introduce code splitting using React Lazy and Suspense.

React.lazy let us render dynamic import like a normal component.

Before:

import About from '../../views/About/About.component';

After:

const About = lazy(() => import('../../views/About/About.component')); 

React.lazy expects function as an argument. This function must invoke dynamic import() and return a Promise, which is going to be resolved to the module with default export containing react component.

Lazy component need to be rendered inside React.Suspense to give us a possibility to render loader or anything we want to display during a time when we asynchronously importing a component. If you try to render Lazy component outside Suspense then your app will crash with following error message "Uncaught Error: A React component suspended while rendering, but no fallback UI was specified."

What react docs says about Suspense:

The fallback prop accepts any React elements that you want to render while waiting for the component to load. You can place the Suspense component anywhere above the lazy component. You can even wrap multiple lazy components with a single Suspense component.

  <Suspense fallback={<div>loading...</div>}>

Now we are ready to implement lazy components.

First we need to define new variables for each view component React.lazy let us import components chunks in asynchronous way within first component render.

const About = lazy(() => import('../../views/About/About.component')); 
const Contact = lazy(() => import('../../views/Contact/Contact.component')); 
const Home = lazy(() => import('../../views/Home/Home.component')); 

then we can remove our previous imports for About, Home and Contact from code and introduce Suspense. Whole code should looks like this

import React, { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

const About = lazy(() => import('../../views/About/About.component')); 
const Contact = lazy(() => import('../../views/Contact/Contact.component')); 
const Home = lazy(() => import('../../views/Home/Home.component')); 

export const AppRouter: React.FC = ({ children }) => (
  <Suspense fallback={<div>loading...</div>}>
    <Router>
      {children}
      <Switch>
        <Route exact path='/' component={Home}/>
        <Route exact path='/contact' component={Contact}/>
        <Route path="/about" component={About}/>
      </Switch>
    </Router>
  </Suspense>
);

Let's check the browser network tab

many-chunks.png

Now you can observe that new chunks appeared. Our code is splitted!

What about components exported not as default

First we need to change one of our view components

import React from 'react';

export const About: React.FC = () => (
  <div>About</div>
);

I removed default export and I am exporting About component directly within constant declaration.

Now AppRouter requires import change for About component

Before:

const About = lazy(() => import('../../views/About/About.component')); 

After:

const About = lazy(() => import('../../views/About/About.component').then(module => ({ default: module.About })));

Let me remind you React.lazy definition from React docs

React.lazy takes a function that must call a dynamic import(). This must return a Promise which resolves to a module with a default export containing a React component.

So, if we do not have a component exported as default we need to manually resolve a promise using then function which takes another function as an argument and we need to return an object with default keyword as a key and module component as a value.

module => ({ default: module.About })

Great, all should work as previously.

Error boundary

Error boundaries are really helpful to display fallback UI to the user if our app crash. I am not going to go really deep to this topic within this article. I will quickly show you how you can improve our routing with ErrorBoundary component

First we need to create ErrorBoundary component

import React from 'react';

interface ErrorBoundaryProps {
  message: string;
}

interface ErrorBoundaryState {
  hasError: boolean;
}

export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: any) {
    return { hasError: true };
  }

  componentDidCatch(error: any, errorInfo: any) {
    console.log(error);
    console.log(errorInfo);
  }

  render() {
    if (this.state.hasError) {
    return <h1>{this.props.message}</h1>;
    }

    return this.props.children; 
  }
}

Now we need to wrap our AppRouter into ErrorBoundary.

export const AppRouter: React.FC = ({ children }) => (
  <ErrorBoundary message={'Router error'}>
    <Suspense fallback={<div>loading...</div>}>
      <Router>
        {children}
        <Switch>
          <Route exact path='/' component={Home}/>
          <Route exact path='/contact' component={Contact}/>
          <Route path="/about" component={About}/>
        </Switch>
      </Router>
    </Suspense>
  </ErrorBoundary>
);

So now if I introduce a spelling mistake to the lazy component import, then my closest Error Boundary should catch and handle it.

error-boundary.png

More about Error Boundaries here:

https://reactjs.org/docs/error-boundaries.html

https://reactjs.org/docs/code-splitting.html#error-boundaries

Summary

We've learned today how to create a dynamic Routing which let us split our application to small code chunks. It is really powerful especially when your application grows and you are looking for optimisation and decreasing bundle size.