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.

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 function. When that route matches, a response will not be emitted until the resolve has resolved.

resolve 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 respond 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 respond 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({ params }) {
    const body = import("./components/SomeComponent")
      .then(preferDefault);
    const data = fetch(`/api/data/${params.id}`);
    return Promise.all([ component, data ]);
  },
  respond({ resolved, error }) {
    if (error) {
      // handle an uncaught error
    }
    const [body, data] = resolved;
    return { body, data };
  }
}

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({ params }) {
      const body = import("./components/SomeComponent")
        .then(preferDefault);
      const data = fetch(`/api/data/${params.id}`);
      return Promise.all([ component, data ]);
    },
    respond({ resolved, error }) {
      if (error) {
        // handle an uncaught error
      }
      const [body, data] = resolved;
      return { body, data };
    }
  }
]);

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.

Currently respond function 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() {
      return import(
        /* webpackChunkName: "Test" */ "./components/Test.js"
      ).then(preferDefault);
    }
  }
]);

When a module fails to load, the error will be passed to the respond 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() {
      return import("./components/One.js")
        .then(preferDefault)
        .catch(err => displayLoadError(err);
    },
    respond({ resolved }) {
      return {
        body: resolved
      };
    }
  }
]);

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 respond 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() {
      return import("./components/Home")
        .then(preferDefault);
    },
    respond({ resolved }) {
      return { body: resolved };
    }
  },
  {
    name: "Book",
    path: "book/:id",
    resolve() {
      return import("./components/Book")
        .then(preferDefault);
    },
    respond({ resolved }) {
      return { body: resolved };
    }
  },
  {
    name: "Checkout",
    path: "checkout",
    resolve() {
      return import("./components/Checkout")
        .then(preferDefault);
    },
    respond({ resolved }) {
      return { body: resolved };
    }
  },
  {
    name: "Catch All",
    path: "(.*)",
    resolve() {
      return import("./components/NotFound")
        .then(preferDefault);
    },
    respond({ resolved }) {
      return { body: resolved };
    }
  }
]);

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 { createRouter, announce } from "@curi/router";
import { browser } from '@hickory/browser';
import { createRouterComponent } from '@curi/react-dom';

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

const router = createRouter(browser, routes, {
  sideEffects: [
    announce(({ response }) => {
      return `Navigated to ${response.location.pathname}`;
    })
  ]
});
const Router = createRouterComponent(router);

router.once(() => {
  ReactDOM.render((
    <Router>
      <App />
    </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 respond 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 { createRouter } from "@curi/router";
import { browser } from '@hickory/browser';
import { createRouterComponent } 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 router = createRouter(browser, routes, {
  sideEffects: [
    announce(({ response }) => {
      return `Navigated to ${response.location.pathname}`;
    })
  ],
  external: {
    bookAPI
  }
});
const Router = createRouterComponent(router);

router.once(() => {
  ReactDOM.render((
    <Router>
      <App />
    </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 respond 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 respond function 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(_, external) {
      const body = import("./components/Home")
        .then(preferDefault);
      const books = external.bookAPI.BOOKS();
      return Promise.all([body, books]);
    },
    respond({ resolved }) {
      const [body, books] = resolved;
      return {
        body,
        data: { books }
      };
    }
  },
  {
    name: "Book",
    path: "book/:id",
    resolve({ params }, external) {
      const body = import("./components/Book")
        .then(preferDefault);
      const book = external.bookAPI.BOOK(params.id);
      return Promise.all([body, books]);
    },
    respond({ resolved }) {
      const [body, book] = resolved;
      return {
        body,
        data: { book }
      };
    }
  },
  {
    name: "Checkout",
    path: "checkout",
    resolve() {
      return import("./components/Checkout")
        .then(preferDefault);
    },
    respond({ resolved }) {
      return { body: resolved };
    }
  },
  {
    name: "Catch All",
    path: "(.*)",
    resolve() {
      return import("./components/NotFound")
        .then(preferDefault);
    },
    respond({ resolved }) {
      return { body: resolved };
    }
  }
]);

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 (
    <article>
      <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>
    </article>
  );
}

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 { useRouter } from '@curi/react-dom';

import cart from '../cart';

export default function Book({ response }) {
  const router = useRouter();
  const { book } = response.data;
  if (!book) {
    return <article>The requested book could not be found</article>;
  }
  return (
    <article>
      <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);
          const url = router.url({ name: "Checkout" });
          router.navigate({ url });
        }}
      >
        Add to Cart
      </button>
    </article>
  );
};

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);
});

The Link component has a sibling component called AsyncLink, can takes a render-invoked function as its children prop. The function is called with a navigating boolean that indicates whether the router is currently navigating to that link. This is useful for when you know that there is a long (multiple seconds) delay between when the user clicks the link and when the navigation will occur.

We can replace the Links in the Home component with AsyncLinks and use render-invoked functions to display a loading spinner while we wait for the book data to load.

import { AsyncLink } from "@curi/react-dom";

<AsyncLink name="Book" params={{ id: 1 }}>
  {navigating => (
    <React.Fragment>
      Book 1
      {navigating ? <Spinner /> : null}
    </React.Fragment>
  )}
</AsyncLink>

We will use the react-spinkit package, which provides a variety of spinner components.

npm install react-spinkit

In the Home component's module, we need to import the Spinner component. The Link needs to be swapped from a React element to a render-invoked function. We wrap the contents in a React.Fragment to avoid unnecessary DOM elements. In the function, we render a Spinner when the Link is navigating and null when it is not.

// src/components/Home.js
import React from 'react';
import { AsyncLink } from '@curi/react-dom';
import Spinner from "react-spinkit";

export default function Home({ response }) {
  return (
    <article>
      <ul>
        {response.data.books.map(book => (
          <li key={book.id}>
            <AsyncLink name="Book" params={{ id: book.id }} >
              {navigating => (
                <React.Fragment>
                  {book.title} by {book.author}
                  {navigating ? <Spinner /> : null}
                </React.Fragment>
              )}
            </AsyncLink>
          </li>
        ))}
      </ul>
    </article>
  );
}

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.

  1. Demo
  2. Setup
  3. Asynchronous Routes
    1. Initial Render
  4. Code Splitting in Routes
    1. Code Splitting
  5. Preloading Data
    1. The Fake API
  6. Visualizing Loading
    1. Link is navigating?
  7. Async Caveats