12 Integrating data fetching with Suspense

This chapter covers

  • Wrapping promises to access their status
  • Throwing promises and errors when fetching data
  • Using Suspense components to specify fallback UI when loading data and images
  • Fetching data and resources as early as possible
  • Recovering from errors when using error boundaries

The React team has a mission to maintain and develop a product that makes it as easy as possible for developers to create great user experiences. In addition to writing comprehensive documentation, providing intuitive and instructive developer tools, authoring descriptive and easily actionable error messages, and ensuring incremental upgrade paths, the team wants React to make it easy to provide fast-loading, responsive, and scalable applications. Concurrent Mode and Suspense offer ways to improve the user experience, orchestrating the loading of code and resources, enabling simpler, intentional loading states, and prioritizing updates that let users get on with their work or play.

But the React team doesn’t want hooking into Concurrent Mode to be a burden on developers; they want as many of the benefits as possible to be automatic and any new APIs to be intuitive and in step with existing mindsets. So, Concurrent Mode is still flagged as experimental as the APIs are tested and tweaked. Hopefully, we won’t be kept in suspense for much longer! [No! We agreed, no suspense jokes—ed]

We’ll get into more of the philosophy and promise of Concurrent Mode in chapter 13. This chapter’s a bit of a bridge between the stable, production use of lazy components and Suspense from chapter 11 and the tentative APIs of deferred rendering, transitions, and SuspenseList components in chapter 13. Here we use the ideas about thrown promises to consider what data fetching with Suspense might look like. The code examples are not for production but offer an insight into what library authors might need to consider in order to work well with Concurrent Mode and Suspense.

12.1 Data fetching with Suspense

In chapter 11, we saw that Suspense components show fallback UI when they catch a thrown promise. There, we were lazy-loading components, and React coordinated the throwing of promises via the lazy function and dynamic imports:

const LazyCalendar = lazy(() => import("./Calendar"));

When trying to render the lazy component, React first checks the component’s status; if the dynamically imported component has loaded, React goes ahead and renders it, but if it’s pending, React throws the dynamic import promise. If the promise is rejected, we need an error boundary to catch the error and show appropriate fallback UI:

<ErrorBoundary>
  <Suspense fallback="Loading...">
    <LazyCalendar/>
  </Suspense>
</ErrorBoundary>

On reaching the LazyCalendar component, React can use the loaded component, throw an existing pending promise, or start the dynamic import and throw the new pending promise.

We want something similar for components that load data from a server. Say we have a Message component that loads and displays a message. In figure 12.1, the Message component has loaded the message “Hello Data!” and is displaying it.

Figure 12.1 The Message component loads a message and displays it.

While data is loading, we want to use a Suspense component to display a fallback like the one in figure 12.2 which says, “Loading message . . . ”.

Figure 12.2 While the data is loading, a Suspense component displays a fallback message.

And if there’s an error, we want an ErrorBoundary component to display a fallback like the one in figure 12.3, which says, “Oops!”

Figure 12.3 If there’s an error, an ErrorBoundary component displays an error message.

The JSX to match our expectations will be something like this:

<ErrorBoundary fallback="Oops!">
  <Suspense fallback="Loading message...">
    <Message/>
  </Suspense>
</ErrorBoundary>

But while we have the lazy function for lazy components, there is no stable, built-in mechanism for components that are loading data. (There is a react-cache package, but it’s experimental and unstable.)

Maybe we can come up with a way of loading data that throws promises or errors as appropriate. In doing so, we’ll gain a little insight into some of the steps that data-fetching libraries will need to implement, but it’s just an insight and definitely not a recommendation for production code. (Once Concurrent Mode and data-fetching strategies for React have settled, and battle-testing has defeated real-world issues and edge cases, look to the libraries like Relay, Apollo, and React Query for efficient, flexible, fully integrated data fetching.) Take a look at the following listing for our Message component. It includes a speculative getMessageOrThrow function.

Listing 12.1 The Message component calls a function to retrieve data

function Message () {
  const data = getMessageOrThrow();                     
  return <p className="message">{data.message}</p>;     
}

Call a function that returns data or throws a promise or error.

Include the data in the UI.

We want the getMessageOrThrow function to return the data if it’s available. If there’s a promise that hasn’t yet resolved to our data, the function should throw it. If the promise has been rejected, the function should throw an error.

The problem is, if there’s a promise for our data (like the one the browser’s fetch API returns, for example), we don’t have a way of checking its status. Is it pending? Has it resolved? Has it been rejected? We need to wrap the promise in code that’ll report its status.

12.1.1 Upgrading promises to include their status

To work with Suspense and ErrorBoundary components, we need to use the status of a promise to dictate our actions. Table 12.1 matches the status with the required action.

Table 12.1 The action for each promise status

Status of promise

Action

Pending

Throw the promise.

Resolved

Return the resolved value—our data.

Rejected

Throw the rejection error.

The promise won’t report its own status, so we want some kind of checkStatus function that returns the current status of the promise and its resolved value or rejection error if available. Something like this:

const {promise, status, data, error} = checkStatus();

Or, because we’ll never get data and error at the same time, something like this:

const {promise, status, result} = checkStatus();

We’d then be able to use conditionals like if (status === "pending") to decide whether to throw promises or errors or to return values.

The following listing shows a getStatusChecker function that takes a promise and returns a function that gives us access to the promise’s status.

Listing 12.2 Getting a function to access the status of a promise

export function getStatusChecker (promiseIn) {     
  let status = "pending";                          
  let result;                                      
 
  const promise = promiseIn
    .then((response) => {                          
      status = "success";
      result = response;
    })
    .catch((error) => {                            
      status = "error";
      result = error;
    });
 
  return () => ({promise, status, result});        
}

Pass in the promise whose status we want to track.

Set up a variable to hold the status of the promise.

Set up a variable for the resolved value or rejection error.

On success, assign the resolved value to result.

On error, assign the rejection error to result.

Return a function to access the current status and result.

Using the getStatusChecker function, we can get the checkStatus function we need to track the status of a promise and react accordingly. For example, if we have a fetchMessage function that returns a promise and loads message data, we could get a status-tracking function like this:

const checkStatus = getStatusChecker(fetchMessage());

Okay, that’s great; we have a promise-status-tracking function. To integrate with Suspense, we need our data-fetching function to use that promise status to either return data, throw a promise, or throw an error.

12.1.2 Using the promise status to integrate with Suspense

Here’s our Message component again:

function Message () {
  const data = getMessageOrThrow();
  return <p className="message">{data.message}</p>;
}

We want to be able to call a data-fetching function—in this case, getMessageOrThrow—that automatically integrates with Suspense by throwing promises or errors as appropriate or returns our data after it’s loaded. The following listing shows the makeThrower function that takes a promise and returns just such a function, one that uses the promise’s status to act appropriately.

Listing 12.3 Returning a data-fetching function that throws as appropriate

export function makeThrower (promiseIn) {                
  const checkStatus = getStatusChecker(promiseIn);       
 
  return function () {                                   
    const {promise, status, result} = checkStatus();     
 
    if (status === "pending") throw promise;             
    if (status === "error") throw result;                
    return result;                                       
  };
}

Pass in the data-fetching promise.

Get a status-tracking function for the promise.

Return a function that can throw.

Get the latest status whenever the function is called.

Use the status to throw or return.

For the Message component, we’ll use makeThrower to transform the promise that the fetchMessage function returns into a data-fetching function that can throw promises or errors:

const getMessageOrThrow = makeThrower(fetchMessage());

But when do we start fetching? Where do we put that line of code?

12.1.3 Fetching data as early as possible

We don’t have to wait until a component has rendered to start loading the data it needs. We can kick off fetching outside the component, using the fetch promise to build a throw-ready data-access function that the component can use. Listing 12.4 shows a full App example for our Message component. The browser executes the code when it loads, starting the data fetch. Once React renders App and then the nested Message, Message calls getMessageOrThrow, which accesses the existing promise.

Live: https://t1lsy.csb.app, Code: https://codesandbox.io/s/suspensefordata-t1lsy

Listing 12.4 Using the Message component

import React, {Suspense} from "react";
import {ErrorBoundary} from "react-error-boundary";
import fetchMessage from "./api";
import {makeThrower} from "./utils";
import "./styles.css";
 
function ErrorFallback ({error}) {
  return <p className="error">{error}</p>;
}
 
const getMessageOrThrow = makeThrower(fetchMessage());                
 
function Message () {
  const data = getMessageOrThrow();                                   
  return <p className="message">{data.message}</p>;                   
}
 
export default function App () {
  return (
    <div className="App">
      <ErrorBoundary FallbackComponent={ErrorFallback}>               
        <Suspense                                                     
          fallback={<p className="loading">Loading message...</p>}
        >
          <Message />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

Start fetching as soon as possible.

Access the data or throw an error or promise.

Use the data if available.

Catch thrown errors.

Catch thrown promises.

Our error boundary is the ErrorBoundary component from the react-error-boundary package, mentioned in chapter 11. We specify its fallback by setting the FallbackComponent prop. The fetchMessage function accepts two arguments to help you test the Suspense and ErrorBoundary fallbacks: a delay in milliseconds and a canError Boolean to randomly cause errors. If you want the request to take three seconds and sometimes fail, then change the call to the following:

const getMessageOrThrow = makeThrower(fetchMessage(3000, true));

In listing 12.4, the Message component can call getMessageOrThrow because it’s in the same scope. That won’t always be the case, so you may want to pass the data-access function to Message as a prop. You may also want to load new data in response to a user action. Let’s see how to work with props and events to make the data-fetching more flexible.

12.1.4 Fetching new data

Say we want to upgrade our Message component to include a Next button, as shown in figure 12.4.

Figure 12.4 The Message component now displays a Next button.

Clicking the Next button will load and display a new message. While the new message is loading, Message will suspend (the getMessageOrThrow function or its equivalent will throw its promise), and the Suspense component will show the “Loading message . . .” fallback UI from figure 12.2 again. Once the promise resolves, Message will display the newly loaded message, “Bonjour,” as shown in figure 12.5.

Figure 12.5 Clicking the Next button loads a new message.

For each new message that we load, we need a new promise and a new data-fetching function that can throw. In listing 12.6, we’ll update the Message component to accept the data-fetching function as a prop. First, listing 12.5 shows the App component managing the current data-fetching function in state and passing it to Message.

Live: https://xue0l.csb.app, Code: https://codesandbox.io/s/suspensefordata2-xue0l

Listing 12.5 The App component holds the current getMessage function in state

const getFirstMessage = makeThrower(fetchMessage());                      
 
export default function App () {
  const [getMessage, setGetMessage] = useState(() => getFirstMessage);    
 
  function next () {
    const nextPromise = fetchNextMessage();                               
    const getNextMessage = makeThrower(nextPromise);                      
    setGetMessage(() => getNextMessage);                                  
  }
 
  return (
    <div className="App">
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <Suspense
          fallback={<p className="loading">Loading message...</p>}
        >
          <Message
            getMessage={getMessage}                                       
            next={next}                                                   
          />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

Fetch the first message straight away.

Keep the current data-fetching function in state.

Start fetching the next message.

Get a data-fetching function that can throw the promise or error.

Update the state to hold the latest data-fetching function.

Pass the current data-fetching function to the Message component.

Give the Message component a way to request the next message.

We pass useState an initialization function that returns the data-fetching function for the first message, getFirstMessage. Notice, we don’t call getFirstMessage; we return it, setting it as the initial state.

App also provides a next function for loading the next message and placing the new data-fetching function in state. The first thing the next function does is start fetching the next message:

const nextPromise = fetchNextMessage();

Our API on CodeSandbox includes the fetchNextMessage function that requests the next message and returns a promise. To integrate with Suspense by throwing a pending promise, next needs to get a promise-throwing function for the data-fetching promise:

const getNextMessage = makeThrower(nextPromise);

The final step is to update the state; it’s holding the current promise-throwing function. Both useState and the updater function it returns, setGetMessage in this case, accept a function as an argument. If you pass them a function, they call useState to get its initial state and setGetMessage to get the new state. Because the state value we’re trying to store is a function itself, we can’t pass it directly to these state-setting functions. We don’t do this:

useState(getFirstMessage); // NOT THIS

And we don’t do this:

setGetMessage(getNextMessage);  // NOT THIS

Instead, we pass useState and setGetMessage functions that return the functions we want to set as state:

useState(() => getFirstMessage);  // Return the initial state, a function

And we use this:

setGetMessage(() => getNextMessage);  // Return the new state, a function

We don’t want to call getNextMessage here; we just want to set it as the new state value. Setting the state value causes App to re-render, passing Message the latest data-fetching function as the getMessage prop.

The updated Message component is in the following listing. It shows the component accepting getMessage and next as props and includes the Next button in the UI.

Live: https://xue0l.csb.app, Code: https://codesandbox.io/s/suspensefordata2-xue0l

Listing 12.6 Pass Message props for data fetching

function Message ({getMessage, next}) {            
  const data = getMessage();
  return (
    <>
      <p className="message">{data.message}</p>
      <button onClick={next}>Next</button>         
    </>
  );
}

Accept the data-fetching function and button handler as props.

Include a Next button in the UI.

Message calls getMessage, which returns the new message data or throws. When a user clicks the Next button, Message calls next, starting to fetch the next message straightaway. And re-rendering straightaway. We’re using the render-as-you-fetch approach, specifying Suspense and ErrorBoundary fallbacks for React to render when components throw promises or errors.

Speaking of errors, our App component is using the ErrorBoundary component from the react-error-boundary package. It has a few more tricks up its sleeve, including easy error recovery. Let’s cast our next spell.

12.1.5 Recovering from errors

Figure 12.6 shows what we’re after; when an error occurs, we want to give users a Try Again button to click, to reset the error state and try rendering the app again.

Figure 12.6 The ErrorBoundary component UI now includes a Try Again button to reset the error boundary and load the next message.

In listing 12.5, we assigned the ErrorFallback component as the FallbackComponent prop for the ErrorBoundary:

<ErrorBoundary FallbackComponent={ErrorFallback}>
  {/* app UI */}
</ErrorBoundary>

The following listing shows a new version of our ErrorFallback component. When ErrorBoundary catches an error and renders the fallback, it automatically passes a resetErrorBoundary function to ErrorFallback.

Live: https://7i89e.csb.app/, Code: https://codesandbox.io/s/errorrecovery-7i89e

Listing 12.7 Adding a button to ErrorFallback

function ErrorFallback ({error, resetErrorBoundary}) {            
  return (
    <>
      <p className="error">{error}</p>
      <button onClick={resetErrorBoundary}>Try Again</button>     
    </>
  );
}

Receive the resetErrorBoundary function from ErrorBoundary as a prop.

Include a button that calls resetErrorBoundary.

The ErrorFallback UI now includes a Try Again button that calls the resetErrorBoundary function to remove the error state and render the error boundary’s children rather than the error fallback UI. In addition to resetting the error state on the error boundary, resetErrorBoundary will also call any reset function that we assign to the error boundary’s onReset prop. In the following listing, we tell ErrorBoundary to call our next function and load the next message whenever we reset the boundary.

Live: https://7i89e.csb.app/, Code: https://codesandbox.io/s/errorrecovery-7i89e

Listing 12.8 Adding an onReset prop to ErrorBoundary

export default function App () {
  const [getMessage, setGetMessage] = useState(() => getFirstMessage);
  function next () {/* unchanged */}
  return (
    <div className="App">
      <ErrorBoundary
        FallbackComponent={ErrorFallback}
        onReset={next}                         
      >
        <Suspense
          fallback={<p className="loading">Loading message...</p>}
        >
          <Message getMessage={getMessage} next={next} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

Include an onReset function that ErrorBoundary will call if reset.

The error boundary now does something to try to shake the app’s error state: it tries to load the next message. Here are the steps it goes through when the Message component throws an error trying to load a message:

  1. The Message component throws an error.

  2. ErrorBoundary catches the error and renders the ErrorFallback component, including the Try Again button.

  3. The user clicks the Try Again button.

  4. The button calls resetErrorBoundary, removing the error state from the boundary.

  5. The error boundary re-renders its children and calls next to load the next message.

Check out the GitHub repository for react-error-boundary to see the rest of its super-helpful error-related tricks: https://github.com/bvaughn/react-error-boundary.

12.1.6 Checking the React docs

In our brief foray into one experimental way of integrating data fetching with Suspense, we created two key functions:

  • getStatusChecker—Provides a window into the status of a promise

  • makeThrower—Upgrades a promise into one that returns data or that throws an error or promise

We used makeThrower to create functions like getMessageOrThrow that the Message component used to get the latest message, throw an error, or throw a promise (suspend). We stored the data-fetching functions in state and passed them to children via props.

The React docs also have an experimental, just for information, be careful—no, really be careful—example of integrating our own promises with Suspense, shown in the following listing, that does the job of our getStatusChecker and makeThrower functions in one wrapPromise function. Read the rationale behind the code in the docs: http://mng.bz/JDBK.

Code: https://codesandbox.io/s/frosty-hermann-bztrp?file=/src/fakeApi.js

Listing 12.9 The wrapPromise function from the React docs examples

// Suspense integrations like Relay implement             
// a contract like this to integrate with React.
// Real implementations can be significantly more complex.
// Don't copy-paste this into your project!
function wrapPromise(promise) {
  let status = "pending";
  let result;
  let suspender = promise.then(                           
    r => {
      status = "success";
      result = r;
    },
    e => {
      status = "error";
      result = e;
    }
  );
  return {
    read() {                                              
      if (status === "pending") {
        throw suspender;
      } else if (status === "error") {
        throw result;
      } else if (status === "success") {
        return result;
      }
    }
  };
}

The code is for interest rather than production use.

The code names the wrapped promise a suspender.

The function returns an object with a read method.

The wrapPromise function doesn’t return a function directly; it returns an object with a read method. So, rather than assigning a function to a local variable, getMessage, like this

const getMessage = makeThrower(fetchMessage());    
 
function Message () {
  const data = getMessage();                       
  // return UI that shows data
}

Assign the data-fetching function to getMessage.

Call getMessage to get data or throw.

we assign an object to a local variable, messageResource, like this:

const messageResource = wrapPromise(fetchMessage());    
 
function Message () {
  const data = messageResource.read();                  
 
  // return UI that shows data
}

Assign the object with data-fetching method to messageResource.

Call the read method to get data or throw.

Which approach is better? Well, I bet the React team thought carefully about its examples and considered many more scenarios in which the concept of a resource with a read method was found to be easier to think about and work with than directly storing, passing, and calling naked data-fetching functions. Having said that, I think our step-by-step exploration of the concepts and procedures involved in integrating data fetching with Suspense has been useful.

Ultimately, this is all still theoretical and experimental and is highly likely to change. Unless you’re a data-fetching library author yourself, you’ll find the nitty-gritty details will be handled by the libraries you use. We’ve been using React Query for our data work; does it integrate with Suspense?

12.2 Using Suspense and error boundaries with React Query

React Query provides an experimental config option to switch on Suspense for queries. Rather than returning status and error information, queries will throw promises and errors. You can find out more about the experimental Suspense integration in the React Query documentation (http://mng.bz/w9A2).

For the bookings app, we’ve been using the status value that useQuery returns to conditionally render loading spinners and error messages. All of our data-loading components have code like this:

  const {data, status, error} = useQuery(     
    "key",
    () => getData(url)
  );
 
  if (status === "error") {                   
    return <p>{error.message}</p>
  }
 
  if (status === "loading") {                 
    return <PageSpinner/>
  }
 
  return ({/* UI with data */});

When loading data, assign status value to a local variable.

Check status and return appropriate UI.

But we’ve now seen how Suspense and ErrorBoundary components let us decouple the loading and error UI from individual components. The bookings app has page-level Suspense and ErrorBoundary components in place, so let’s switch over our queries to use the existing components.

Branch: 1201-suspense-data, File: /src/components/Bookables/BookablesView.js

Listing 12.10 The BookablesView component with Suspense integration

import {Link, useParams} from "react-router-dom";
import {FaPlus} from "react-icons/fa";
 
import {useQuery} from "react-query";
import getData from "../../utils/api";
 
import BookablesList from "./BookablesList";
import BookableDetails from "./BookableDetails";
// no need to import PageSpinner
 
export default function BookablesView () {
  const {data: bookables = []} = useQuery(
    "bookables",
    () => getData("http://localhost:3001/bookables"),
    {
      suspense: true                          
    }
  );
 
  const {id} = useParams();
  const bookable = bookables.find(
    b => b.id === parseInt(id, 10)
  ) || bookables[0];
 
  // no status checks or loading/error UI     
 
  return ({/* unchanged UI */});
}

Pass a config object with suspense set to true.

Remove the status-checking code for loading and error states.

The updated BookablesView component passes a configuration option to useQuery when loading the bookables data:

const {data: bookables = []} = useQuery(
  "bookables",
  () => getData("http://localhost:3001/bookables"),
  {
    suspense: true 
  }
);

That config option tells useQuery to suspend (throw a promise) when loading its initial data and to throw an error if something goes wrong.

Challenge 12.1

Update the BookingsPage and UsersList components to use Suspense when loading their data. Remove any unnecessary loading and error state UI that’s embedded within the components. The current branch includes the changes: 1201-suspense-data.

12.3 Loading images with Suspense

Suspense works great with lazily-loaded components and, at least tentatively, can be integrated with the promises that arise naturally when loading data. How about other resources like scripts and images, for example? The key is the promise: if we can wrap our requests in promises, we can (at least experimentally) work with Suspense and error boundaries to provide fallback UI. Let’s look at a scenario for integrating image loading with Suspense.

Your boss is keen for you to make the Users page more useful, wanting you to include an avatar image for each user and, later, details of each user’s bookings and tasks. We’ll get to the bookings and tasks in the next chapter. Here, we aim to include an avatar image like the Japanese castle shown in figure 12.7.

Figure 12.7 The UserDetails component includes an avatar image for each user.

The 1202-user-avatar branch of the GitHub repo includes separate components for the list of users and the details of the selected user, UsersList and UserDetails, with management of the selected user in the UsersPage component. The repo also has avatar images in the /public/img folder. UsersPage now passes UserDetails just the ID of the selected user, and the UserDetails component loads the user’s details and then renders the avatar as a standard img element:

<div className="user-avatar">
  <img src={`http://localhost:3001/img/${user.img}`} alt={user.name}/>
</div>

Unfortunately, at slow network speeds and with large avatar image files, the images can take a while to load, leading to the poor user experience shown in figure 12.8, where the image (a butterfly on a flower) appears bit by bit. You can use your browser’s developer tools to throttle the network speed.

Figure 12.8 When switching users, the avatar image might take a while to load, potentially resulting in a poor user experience. Here, only half of the image has loaded so far.

In this section, we explore a couple of ways of improving the user experience for slow-loading images within our user interface:

  • Using React Query and Suspense to provide an image-loading fallback

  • Prefetching images and data with React Query

Together, the two approaches help provide users with a predictable user interface where, hopefully, slow-loading assets won’t call attention to themselves, degrading the experience of using the app.

12.3.1 Using React Query and Suspense to provide an image-loading fallback

We want to show some kind of fallback while images load, maybe a shared avatar placeholder with a small file size, like the head silhouette image shown in figure 12.9.

Figure 12.9 While the avatar image is loading, we can show a placeholder image that has a small file size and that could be loaded earlier.

To integrate with Suspense, we need an image-loading process that throws a promise until the image is ready to use. We create the promise manually, around the DOM HTMLImageElement Image constructor like this:

const imagePromise = new Promise((resolve) => {
  const img = new Image();                        
  img.onload = () => resolve(img);                
  img.src = "path/to/image/image.png"             
});

Create a new image object.

Resolve the promise when the image finishes loading.

Start loading the image by specifying its source.

We then need an image-loading function that throws the promise while it’s pending:

const getImageOrThrow = makeThrower(imagePromise);

And, finally, a React component that calls the function, rendering the image after it has loaded:

function Img () {
  const imgObject = getImageOrThrow();                
 
  return <img src={imgObject.src} alt="avatar" />     
}

Get the image object or throw a promise.

Once the image is available, render a standard img element.

But we don’t want to be continually reloading the image on every render, so we need some kind of cache. Well, we already have one of those built into React Query. So, rather than building our own cache and throwing our own promises, let’s hook into React Query’s Suspense integration (not forgetting that it’s experimental). The following listing shows an Img component that throws pending promises until its image has loaded.

Branch: 1203-suspense-images, File: /src/components/Users/Avatar.js

Listing 12.11 An Img component that uses React Query

function Img ({src, alt, ...props}) {
  const {data: imgObject} = useQuery(                        
    src,                                                     
    () => new Promise((resolve) => {                         
      const img = new Image();
      img.onload = () => resolve(img);
      img.src = src;
    }),
    {suspense: true}                                         
  );
 
  return <img src={imgObject.src} alt={alt} {...props}/>     
}

Use React Query for caching, deduping, and throwing.

Use the image src as the query key.

Pass useQuery a function that creates an image-loading promise.

Throw pending promises and errors.

Return a standard img element after the image has loaded.

Using multiple Img components with the same source won’t try to load the image multiple times; React Query will return the cached Image object. (The image itself will be cached by the browser.)

In the bookings app, we want an Avatar component that uses Suspense to show a fallback while the image is loading. The following listing uses the Img component along with a Suspense component to achieve our goal.

Branch: 1203-suspense-images, File: /src/components/Users/Avatar.js

Listing 12.12 An Avatar component that uses Img and Suspense

import {Suspense} from "react";
import {useQuery} from "react-query";
 
export default function Avatar ({src, alt, fallbackSrc, ...props}) {   
  return (
    <div className="user-avatar">
      <Suspense
        fallback={<img src={fallbackSrc} alt="Fallback Avatar"/>}      
      >
        <Img src={src} alt={alt} {...props}/>                          
      </Suspense>
    </div>
  );
}

Specify fallbackSrc and src props.

Use the fallbackSrc prop to show an image as a Suspense fallback.

Use the Img component to integrate with the Suspense component.

The UserDetails component can now use an Avatar to show a fallback image until the desired image has loaded, as implemented in the following listing.

Branch: 1203-suspense-images, File: /src/components/Users/UserDetails.js

Listing 12.13 Using the Avatar component in UserDetails

import {useQuery} from "react-query";
import getData from '../../utils/api';
import Avatar from "./Avatar";
 
export default function UserDetails ({userID}) {              
  const {data: user} = useQuery(
    ["user", userID],
    () => getData(`http://localhost:3001/users/${userID}`),   
    {suspense: true}
  );
  return (
    <div className="item user">
      <div className="item-header">
        <h2>{user.name}</h2>
      </div>
      <Avatar                                                 
        src={`http://localhost:3001/img/${user.img}`}
        fallbackSrc="http://localhost:3001/img/avatar.gif"
        alt={user.name}
      />
      <div className="user-details">
        <h3>{user.title}</h3>
        <p>{user.notes}</p>
      </div>
    </div>
  )
}

Pass in the ID of the user to show.

Load the data for the specified user.

Show an avatar, specifying the image and fallback sources.

We could even preload the fallback image by adding a link element with rel= "prefetch" to the page’s head element, or by imperatively preloading it in a parent component. Let’s look at preloading data and images now.

12.3.2 Prefetching images and data with React Query

At the moment, the UserDetails component doesn’t render the Avatar until the user data has finished loading. We wait for the user data before requesting the image we need, creating a waterfall, as shown in figure 12.10.

Figure 12.10 The Waterfall panel shows that the image for user 2 (user2.png) isn’t requested until the data for user 2 has finished loading.

The second row shows the data for user 2 loading. The third row shows the image for user 2, user2.png, loading. Here are the steps from click to image when we select a user in the users list:

  1. A user is selected.

  2. UserDetails loads the user information, suspending until the data loads.

  3. Once the data has loaded, UserDetails renders its UI, including the Avatar component.

  4. Avatar renders the Img component, which requests the image and suspends until the image has loaded.

  5. Once the image has loaded, Img renders its UI, an img element.

The image doesn’t start loading until the user data has arrived. But the image filename is predictable. Can we start loading the image at the same time as the user information, as shown in the last two rows of figure 12.11?

Figure 12.11 We want the user 2 image and data to load concurrently, as in the last two rows of the figure.

The user selection is managed in the UsersPage component by the switchUser function. To get the concurrent loading shown in figure 12.11, let’s get React Query to start fetching the user data and image at the same time. The following listing includes the two new prefetchQuery calls.

Branch: 1204-prefetch-query, File: /src/components/Users/UsersPage.js

Listing 12.14 Preloading images and data on the Users page

// other imports
 
import {useQueryClient} from "react-query";
import getData from "../../utils/api";
 
export default function UsersPage () {
  const [loggedInUser] = useUser();
  const [selectedUser, setSelectedUser] = useState(null);
  const user = selectedUser || loggedInUser;
  const queryClient = useQueryClient();
 
  function switchUser (nextUser) {
    setSelectedUser(nextUser);
 
    queryClient.prefetchQuery(                                       
      ["user", nextUser.id],
      () => getData(`http://localhost:3001/users/${nextUser.id}`)
    );
 
    queryClient.prefetchQuery(                                       
      `http://localhost:3001/img/${nextUser.img}`,
      () => new Promise((resolve) => {
        const img = new Image();
        img.onload = () => resolve(img);
        img.src = `http://localhost:3001/img/${nextUser.img}`;
      })
    );
  }
 
  return user ? (
    <main className="users-page">
      <UsersList user={user} setUser={switchUser}/>
 
      <Suspense fallback={<PageSpinner/>}>
        <UserDetails userID={user.id}/>                              
      </Suspense>
    </main>
  ) : null;
}

Prefetch the user information.

Prefetch the user avatar image.

Render the user details, including the avatar.

By fetching data and images as early as possible, we don’t keep users waiting as long and reduce the chance of needing our fallback image. But switching to a new user still hits the visitor with a loading spinner (like the one in figure 12.12) if they haven’t viewed that user before. Switching from the details panel to a loading spinner and back to the next details panel is not the smoothest experience.

Figure 12.12 Switching to another user brings up a loading spinner on slower connections.

Rather than the jarring experience of replacing the details with a spinner, it would be better if we could hold off, and switch straight from one set of user details to another, avoiding the receded loading state, the feeling of going back to a spinner. React’s Concurrent Mode promises to make such deferred transitions much easier, and you’ll see how in chapter 13 when we introduce our last two hooks: useTransition and useDeferredValue.

Summary

  • Experiment with data-fetching integration for Suspense but don’t use it in production code yet; it’s not stable and will probably change.

  • When the time comes, use well-tested, reliable data-fetching libraries to manage Suspense integration for you.

  • To tentatively explore data fetching with Suspense, wrap promises with functions that can check their status:

    const checkStatus = getStatusChecker(promise);
  • To integrate with Suspense, data-fetching functions should throw pending promises and errors or return loaded data. Create a function to turn a data-fetching promise into one that throws as necessary:

    const getMessageOrThrow = makeThrower(promise);
  • Use the prepared data-fetching function within a component to get data for the UI or to throw as appropriate:

    function Message () {
      const data = getMessageOrThrow();
      return <p>{data.message}</p>
    }
  • Start loading data as early as possible, maybe in event handlers.

  • Provide ways for users to recover the app from error states. Libraries like react-error-boundary can help.

  • Check out the React docs and its linked examples to gain further insight into these techniques and to see their use of resources with read methods (http:// mng.bz/q9AJ).

  • Use similar promise-wrangling techniques to load other resources like images or scripts.

  • Harness libraries like React Query (in Suspense mode) to manage caching and multiple requests when fetching data or images.

  • Load resources earlier by calling React Query’s queryClient.prefetchQuery method.

  • Avoid waterfalls, whereby later data-fetches wait for previous ones before starting, if possible.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.111.125