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)
You're really good at this. I'm quite new to suspense and things are bit more clear now. Thank you