React Advanced Tutorial

In this tutorial, we will be expanding on the website built in the React basics tutorial. We will take advantage of Curi's async features to add code splitting and data preloading to the application.

We will be doing the following:

  • Add code splitting to routes.
  • Preload route data with asynchronous navigation.

Demo

You can run a demo of the site we are building with CodeSandbox.

Use the three buttons at the top of the Sandbox to toggle view modes. Clicking the menu button in the top left corner opens a menu to switch between files.

Setup

If you did not complete the React basics tutorial, you should either clone its repo or fork its sandbox.

If you are cloning the repo, you should also install its dependencies and then start the development server.

git clone https://github.com/curijs/react-basic-tutorial react-advanced-tutorial
cd react-advanced-tutorial
npm install
npm run start

Asynchronous Routes

Curi lets you attach async functions to a route through its resolve. When that route matches, a response will not be emitted until the async functions have completed.

The async functions for a route are grouped under the route's resolve object. Async functions will be passed an object of the matched route properties, which you may use to specify what data to load.

The results of the async functions will be available in a route's response() function through the resolved object. Each result will be stored in the object using the async function's name.

If any of the async functions throws an uncaught error, that error will be available in the response() function through the error property. That said, it is preferable for you to catch and handle the errors yourself.

{
  name: "A Route",
  path: "route/:id",
  resolve: {
    component: () => import("./components/SomeComponent").then(preferDefault),
    data: ({ params }) => fetch(`/api/data/${params.id}`)
  },
  response({ resolved, error }) {
    // resolved = { component: ..., data: ... }
    if (error) {
      // handle an uncaught error
    }
    return {
      body: resolved.component,
      data: resolved.data
    }
  }
}
Note: These async functions are called every time a route matches. If you have functions that should re-use the results from previous calls, you will probably want to implement some caching. Curi provides a once() function for simple caching, but leaves more advanced caching solutions to the user.

Curi uses Promises to manage async code, so async functions should return Promises. Promise.resolve() can be used to wrap a return value in a Promise.

import { preferDefault } from "@curi/helpers";
          
const routes = prepareRoutes([
  {
    name: "A Route",
    path: "route/:id",
    resolve: {
      component: () => import("./components/SomeComponent")
        .then(preferDefault),
      data: ({ params }) => fetch(`/api/data/${params.id}`)
    },
    response({ resolved, error }) {
      if (error) {
        // ...
      }
      return {
        body: resolved.component,
        data: resolved.data
      }
    }
  }
]);

Initial Render

There is one caveat to async routes: we cannot safely render the application immediately on load because the initial response might not be ready yet.

Curi does not emit a response object to its observers until it is ready. If the initial route that matches is asynchronous, then there is a delay between when the application is ready to render and when there is a response to render.

If you attempt to render immediately after creating a router and the initial response is still being created, the response that will be passed to the <Router>'s children() will be null.

There are a few possible ways to handle this situation.

The first is to delay rendering by placing your ReactDOM.render() call inside of a router.once() callback. This will guarantee that the render isn't called until the first response is ready.

// delay rendering
router.once(() => {
  ReactDOM.render((
    <Router>
      {...}
    </Router>
  ), holder);
});

Alternatively, you can update the render-invoked children() function to know what to do when response is null.

// render fallback when response is null
ReactDOM.render((
  <Router>
    {({ response }) => {
      if (response == null) {
        return <div>Loading...</div>;
      }
      const { body:Body } = response;
      return <Body response={response} />;
    }}
  </Router>
), holder);

Which approach is best will depend on the specifics of an application. If there are routes that will take a long time for the initial load, you will probably want to render something while they load. For async code with short loading times, a blank screen might be more acceptable.

For more information on async route properties, please refer to the routes guide.

Code Splitting in Routes

Currently, the routes.js module imports all of the route modules at the top of the file. This results in a single bundle of all of a website's code. This can be improved by adding code splitting to an application, which will result in more, but smaller, bundles.

Code Splitting

Code splitting works by "dynamically" importing modules using the import() function. When bundlers like Webpack see import() functions, they know to create a separate bundle for that module (and that module's imports, etc.).

You can add a /* webpackChunkName: "chunkName" */ comment to an import() call to let Webpack know what to name a code split bundle.

Create React App's default configuration is already setup to support code splitting, but if you were creating your own Webpack configuration, you would need to use output.chunkFilename to support code splitting.

// this creates a "Test" bundle
import(/* webpackChunkName: "Test" */ "./components/Test.js")
import() returns a module object, so if you want to access a module's default export, you can use a then function to get that value.
import("some-module.js")
  .then(module => module.default)

Currently response() returns an object whose body property is a module imported at the top of the file. In order to add code splitting to routes, we can add a resolve function that imports the module.

The @curi/helpers package provides a preferDefault function. This function will return an imported module's default property if it exists, and returns the entire module if it doesn't have a default property.

import { preferDefault } from "@curi/helpers";
          
const routes = prepareRoutes([
  {
    name: "Test",
    path: "test",
    resolve: {
      body: () => import(/* webpackChunkName: "Test" */ "./components/Test.js")
        .then(preferDefault)
    }
  }
]);

When a module fails to load, the error will be passed to the response() function through the error property. We won't be incorporating this into the application here, but in a real application you probably want to have a fallback component to display an error message (especially if you have an offline mode with service workers).

import displayLoadError from "./components/LoadError";
        
const routes = prepareRoutes([
  {
    name: "One",
    path: "one",
    resolve: {
      body: () => import("./components/One.js")
        .then(preferDefault)
        .catch(err => displayLoadError(err)
    },
    response({ resolved }) {
      return {
        body: resolved.body
      };
    }
  }
]);

We can now update the routes.js module to remove the imports at the top of the file and use import() to import the route components. We will use preferDefault to only resolve the component instead of the entire module object.

The response() functions should also be updated to set the return object's body property to resolved.body instead of the import at the top of the file.

// src/routes.js
import { prepareRoutes } from "@curi/router";
import { preferDefault } from "@curi/helpers";

export default prepareRoutes([
  {
    name: "Home",
    path: "",
    resolve: {
      body: () => import("./components/Home")
        .then(preferDefault)
    },
    response({ resolved }) {
      return { body: resolved.body };
    }
  },
  {
    name: "Book",
    path: "book/:id",
    resolve: {
      body: () => import("./components/Book")
        .then(preferDefault)
    },
    response({ resolved }) {
      return { body: resolved.body };
    }
  },
  {
    name: "Checkout",
    path: "checkout",
    resolve: {
      body: () => import("./components/Checkout")
        .then(preferDefault)
    },
    response({ resolved }) {
      return { body: resolved.body };
    }
  },
  {
    name: "Catch All",
    path: "(.*)",
    resolve: {
      body: () => import("./components/NotFound")
        .then(preferDefault)
    },
    response({ resolved }) {
      return { body: resolved.body };
    }
  }
]);

For this tutorial, we will use router.once() to delay the initial render while we wait for the initial response. We should update the index.js module to do this.

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { curi } from '@curi/router';
import Browser from '@hickory/browser';
import { curiProvider } from '@curi/react-dom';

import routes from './routes';
import './index.css';
import NavMenu from './components/NavMenu';
import registerServiceWorker from './registerServiceWorker';

const history = Browser();
const router = curi(history, routes);
const Router = curiProvider(router);

router.once(() => {
  ReactDOM.render((
    <Router>
      {({ response, router }) => {
        const { body:Body } = response;
        return (
          <div>
            <header>
              <NavMenu />
            </header>
            <main>
              <Body response={response} router={router} />
            </main>
          </div>
        );
      }}
    </Router>
  ), document.getElementById('root'));
});
registerServiceWorker();

With those changes, Webpack will now split the application into multiple bundles. The initial render will be delayed until after the code split bundle for the first route has been loaded.

Preloading Data

Preloading data lets you delay navigation until after the data for a route has loaded. This can save you from having to render a partial page with spinners if the data takes a while to load.

While the data is loading, the user will be able to continue interacting with the current page. This means that the user can also start a new navigation while the current navigation is running. When this happens, Curi knows to to cancel the previous navigation and perform the new navigation instead.

We have two routes that need to load data: Home and Book. The Home route will load the known books, while the Book route will load data about a specific book.

Currently the data for both of these routes is imported in their components. In a real site you would most likely make API calls to a REST or GraphQL endpoint, but here we will simulate this with a fake API.

The Fake API

The fake API will simulate asynchronous calls to the server by returning Promises, similarly to the Fetch API.

First, we will create an api.js module that exports the fake API functions.

touch src/api.js

In the API module, we will import the books.js data.

We need to write two functions. The first returns a list of all books and the second returns the data for a specific book. For both, we can use Promise.resolve() to return a Promise, even though we don't really have any asynchronous code being run.

// src/api.js
import books from "./books";

export const BOOKS = () => Promise.resolve(books);

export const BOOK = id => Promise.resolve(
  const intID = parseInt(id, 10);
  books.find(b => b.id === intID)
);

When the router is created, it can take a third argument, which is an options object. One of the properties of this object is external, which is used to pass in external values that will be accessible in a route's resolve and response functions. This is particularly useful for data that is initialized at runtime, like an Apollo store, but we will also use it here.

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { curi } from '@curi/router';
import Browser from '@hickory/browser';
import { curiProvider } from '@curi/react-dom';

import routes from './routes';
import './index.css';
import NavMenu from './components/NavMenu';
import * as bookAPI from "./api";
import registerServiceWorker from './registerServiceWorker';

const history = Browser();
const router = curi(history, routes, {
  external: {
    bookAPI
  }
});
const Router = curiProvider(router);

router.once(() => {
  ReactDOM.render((
    <Router>
      {({ response, router }) => {
        const { body:Body } = response;
        return (
          <div>
            <header>
              <NavMenu />
            </header>
            <main>
              <Body response={response} router={router} />
            </main>
          </div>
        );
      }}
    </Router>
  ), document.getElementById('root'));
});
registerServiceWorker();

What do we want to do with the data loaded from the API calls? Along with the body property, another valid return property for response functions is data. This is a convenient way to attach any data to a response, which we can read from while rendering.

The Home route already has an asynchronous action: importing the body component. We will name the async call to load the books data "books".

The Book route's response() also needs to be updated to attach the books data (resolved.books) to the response.

The book() API call expects to be given the id number of the book it should return data for. We can grab the correct param (id) from the params property. However, when params are parsed, they are stored as strings. To convert it to a number, we can use the route's params property to tell Curi how to parse the id. By giving it a function that calls parseInt() on the provided value, params.id will be a number instead of a string.

// src/routes.js
import { prepareRoutes } from "@curi/router";
import { preferDefault } from "@curi/helpers";

export default prepareRoutes([
  {
    name: "Home",
    path: "",
    resolve: {
      body: () => import("./components/Home")
        .then(preferDefault),
      books: (match, external) => external.bookAPI.BOOKS()
    },
    response({ resolved }) {
      return {
        body: resolved.body,
        data: { books: resolved.books }
      };
    }
  },
  {
    name: "Book",
    path: "book/:id",
    resolve: {
      body: () => import("./components/Book")
        .then(preferDefault),
      book: ({ params }, external) => external.bookAPI.BOOK(params.id)
    },
    response({ resolved }) {
      return {
        body: resolved.body,
        data: { book: resolved.book }
      };
    }
  },
  {
    name: "Checkout",
    path: "checkout",
    resolve: {
      body: () => import("./components/Checkout")
        .then(preferDefault)
    },
    response({ resolved }) {
      return { body: resolved.body };
    }
  },
  {
    name: "Catch All",
    path: "(.*)",
    resolve: {
      body: () => import("./components/NotFound")
        .then(preferDefault)
    },
    response({ resolved }) {
      return { body: resolved.body };
    }
  }
]);

With the data attached to our responses, we can remove the data imports from the components and just read from the response.

In the <Home> component's module, we can remove the books.js import and grab the response from the component's props. The books data can be access as response.data.books.

// src/components/Home.js
import React from 'react';
import { Link } from '@curi/react-dom';

export default function Home({ response }) {
  return (
    <div>
      <ul>
        {response.data.books.map(book => (
          <li key={book.id}>
            <Link name="Book" params={{ id: book.id }} >
              {book.title} by {book.author}
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

Likewise, we can remove the books.js import from the <Book> component's module and grab the book data from response.data instead of searching for it in the books array.

// src/components/Book.js
import React from 'react';

import cart from '../cart';

export default function Book({ response, router }) {
  const { book } = response.data;
  if (!book) {
    return <div>The requested book could not be found</div>;
  }
  return (
    <div>
      <h1>{book.title}</h1>
      <h2>by {book.author}</h2>
      <p>Published in {book.published}</p>
      <p>{book.pages} pages</p>
      <button
        type="button"
        onClick={() => {
          cart.add(book, 1);
          router.navigate({ to: "Checkout" });
        }}
      >
        Add to Cart
      </button>
    </div>
  );
};

Visualizing Loading

At this point, we have the same functionality as the basic tutorial, but we have added async data loading. The bundle importing has real loading times, but the fake API calls resolve immediately, which doesn't necessarily reflect real world performance.

We can update the fake API to delay resolving so that we can take a look at some of the @curi/react-dom components that are navigation-aware. The implementation here isn't important, so you can just copy+paste the code. The only thing to know is that the BOOKS() function has a one second delay and the BOOK() function has a 2.5 second delay the first time a book is requested (and responds instantly on subsequent calls).

// src/api.js
import books from "./books";

export const BOOKS = () => new Promise(resolve => {
  // artificial delay
  setTimeout(() => {
    resolve(books);
  }, 1000);
});

const BOOK_CACHE = {};
export const BOOK = id => new Promise(resolve => {
  if (BOOK_CACHE[id]) {
    resolve(BOOK_CACHE[id]);
    return;
  }
  const intID = parseInt(id, 10);
  // artificial delay on first call
  setTimeout(() => {
    const book = books.find(b => b.id === id);
    BOOK_CACHE[id] = book;
    resolve(book);
  }, 2500);
});

Async Caveats

Adding asynchronous loading to an application can help reduce initial load size and speed up user interactions, however it also has some issues that you will need to consider.

The biggest consideration is that there is nothing the frontend can do to get the data for the initial render faster. Your application's frontend can only fetch data as it discovers it needs it. If you are performing server-side rendering, you may want to load the initial data on the server and inject it into the page's HTML output. The implementation details for this vary greatly and are more related to how you store data (e.g. with redux).

Another consideration is whether or not you want to "hoist" data requirements. Curi's async functionality relies on you knowing all of the data requirements for a route, but you might prefer to keep the data associated with individual components. React Suspense will help with this (and Curi will support it once it releases), but this is still a ways out. At the very least, I would recommend using Curi for code splitting routes. Whether your should hoist other data requirements is something that should be determined on a case-by-case basis.