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.
You can run a demo of the site we are building with CodeSandbox.
If you are cloning the repo, you should also install its dependencies and then start the development server.
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.
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.
For more information on async route properties, please refer to the routes guide.
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.
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.
@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.
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).
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.
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.
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.
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 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 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 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.
In the API module, we will import the
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.
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
respond functions. This is particularly useful for data that is initialized at runtime, like an Apollo store, but we will also use it here.
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.
Home route already has an asynchronous action: importing the
body component. We will name the async call to load the books data
respond function also needs to be updated to attach the books data (
resolved.books) to the response.
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.
With the data attached to our responses, we can remove the data imports from the components and just read from the response.
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
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.
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).
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.
We will use the
react-spinkit package, which provides a variety of spinner components.
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.
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.