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.
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.
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.
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.
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 undefined
.
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.
Alternatively, you can update the root App
component to detect when the response
is undefined
and render a loading message.
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 set a chunk's name using the webpackChunkName
magic comment with an import
call.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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
.
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.
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).
Link is navigating?
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 Link
s in the Home
component with AsyncLink
s 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.
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.
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.