DEV Community

Andrei
Andrei

Posted on • Updated on

Fetching resources in a React Suspense world

In my previous article, I talked about how we can implement a data fetching pattern that we can use today with React Suspense.

But Suspense is not just about fetching data in a declarative way, but about fetching resources in general, including data. We saw how we can wrap api functions with the useAsyncResource hook, but what about other resources like images, sound files, and scripts?

Well, let's try to build that.

Building with what we have

Our custom useAsyncResource hook from my previous article is already doing enough, and we saw how flexible it is, being able to work with any function that returns a Promise.

Not sure what useAsyncResource is? Check it out on GitHub: https://github.com/andreiduca/use-async-resource

const fetchUser = id => fetch(`/path/to/user/${id}`).then(r => .json());

// in AppComponent.jsx
const [userDataReader] = useAsyncResource(fetchUser, 1);

// pass `userDataReader` to a "suspendable" child component

This looks simple enough when working with data fetching, because, well, fetch will make an api call, then return the response from the server, response that we read and display in our JSX. But what about images for example? How can we "read" the image "response" from the server?

Well, we don't. And we don't actually need to!

Preloading images

See, when displaying images in HTML, we have an img tag with an src attribute. Nothing more declarative than that! As soon as the browser sees that src tag, it will start downloading the image, displaying it progressively as its data comes through the wire. The image then becomes a resource of the page and, in most cases, the browser caches it. When the image is needed later, the browser will serve it immediately, not needing to download all its data again.

So what we really want in our case is actually to not render any img tag until we have the image already downloaded in the browser's cache. We want to display the image all at once, showing a loader until we have it in full. All we need to do is tell the browser to download the image (our resource) in the background, then tell us when that's done, so we can safely render our img tag.

Luckily, we don't need to actually render an img tag into the DOM to start downloading an image. We can do it in memory:

// create an img tag in memory
const img = document.createElement('img');

// attach a function to the `onload` handler
img.onload = () => {
  console.load("Image was downloaded!");
}

// as soon as we set the src attribute
// the browser will start downloading that image
img.src = '/some/path/to/an/image.jpg';

Turning it into a Promise

This is fine how it is, but we need a Promise. Actually, a function that returns a Promise. Let's create one:

function imageResource(filePath) {
  return new Promise((resolve) => {
    const img = document.createElement('img');

    img.onload = () => resolve(filePath);
    img.src = filePath;
  });
}

Nothing more simple than that. We now have a... function, that returns a... Promise, that just resolves with the input (the file path) when it finishes. A function, that returns a Promise, that resolves with a string. Just like we were doing with our api functions all along.

Using it with our hook

By now you probably guessed that this will immediately work with our custom useAsyncResource hook:

// in a UserComponent.jsx
const user = props.userReader();

// initialize the image "data reader"
const [userImageReader] = useAsyncResource(imageResource, user.profilePic);

return (
  <article>
    <React.Suspense fallback={<SomeImgPlaceholder />}>
      <ProfilePhoto resource={userImageReader} />
    </React.Suspense>
    <h1>{user.name}</h1>
    <h2>{user.bio}</h2>
  </article>
);

// in ProfilePhoto.jsx
const imageSrc = props.resource();

return <img src={imageSrc} />;

And that's it. The user image won't be rendered at all until the browser downloads it in the background.

A better user experience

But there will still be a flash of content here: the user name and bio will show for a brief moment along an image placeholder, then the actual image will show on the page. Wouldn't it be nice if we didn't show the entire user component until both the user data AND the image are downloaded, so we avoid a flash of content?

Well, remember that our custom hook caches the resources. So calling useAsyncResource(imageResource, someFilePath) multiple times won't trigger multiple identical api calls.

In our case, we're safe to remove the nested Suspense boundary and just render the profile photo alongside the other user info. The outer Suspense boundary that wraps the entire user object will try to render the user component until it doesn't throw anymore. This will call useAsyncResource multiple times for our user.profilePic file resource. But we don't care, because the first call will cache it, then all subsequent calls will use the first cached version.

So what we end up with is the simpler (and more user friendly) version:

function App() {
  // initialize the user data reader
  // immediately start fetching the user with id `someId`
  const [userDataReader] = useAsyncResource(fetchUser, someId);

  return (
    // pass the data reader to the user component
    // rendering an entire fallback until we have all we need
    <React.Suspense fallback={<><ImgFallback /><UserNameFallback /></>}>
      <User userReader={userDataReader} />
    </React.Suspense>
  );
}

function User(props) {
  // get the user data
  const user = props.userReader();

  // initialize the image "data reader" with the url for the profile pic
  // subsequent initializations will use the cached version anyways
  const [userImageReader] = useAsyncResource(imageResource, user.profilePic);

  // try rendering the profile image with the other user data
  // this will throw if the image was not downloaded yet
  return (
    <article>
      <ProfilePhoto resource={userImageReader} />
      <h1>{user.name}</h1>
      <h2>{user.bio}</h2>
    </div>
  );
}

function ProfilePhoto(props) {
  const imageSrc = props.resource();
  return <img src={imageSrc} />;
}

You can't get more declarative than that!

Images vs. scripts

Unlike an image, a script resource cannot be simply loaded in the background just by setting the src attribute. Instead, we will have to add the script tag to our DOM. But we can still hook into the onload handler to know when the script was loaded on our page.

function scriptResource(filePath: string) {
  return new Promise<string>((resolve) => {
    const scriptTag = document.createElement('script');
    scriptTag.onload = () => {
      resolve(filePath);
    };

    scriptTag.src = filePath;

    // appending the tag to the boody will start downloading the script
    document.getElementsByTagName('body')[0].appendChild(scriptTag);
  });
}

Using this scriptResource helper becomes just as easy:

const [jq] = useAsyncResource(scriptResource, 'https://code.jquery.com/jquery-3.4.1.slim.min.js');

return (
  <AsyncResourceContent fallback="jQuery loading...">
    <JQComponent jQueryResource={jq} />
  </AsyncResourceContent>
);

// and in JQComponent.tsx
const jQ = props.jQueryResource();
console.log('jQuery version:', window.jQuery.fn.jquery);

// jQuery should be available and you can do something with it

Notice we don't do anything with the const jQ, but we still need to call props.jQueryResource() so it can throw, rendering our fallback until the library is loaded on the page.

Of course, this is a contrived example. But it illustrates how you can dynamically load a 3rd party library before accessing anything from it.

Conclusion

As we showed in the previous article, adopting React Suspense can make your code simpler to write, read, and understand. It helps you avoid common traps that the async world can set for you, and lets you focus only on writing your stateful UI. And bringing images (or any other resources for that matter) into the mix shouldn't be any harder than that.

Top comments (1)

Collapse
 
monfernape profile image
Usman Khalil

You're really good at this. I'm quite new to suspense and things are bit more clear now. Thank you