Suspense is an exciting, upcoming feature of React that will enable developers to easily allow their components to delay rendering until they’re “ready,” leading to a much smoother user experience. “Ready,” in this context, can mean a number of things. For example, your data loading utility can tie into Suspense, allowing for consistent loading states to be displayed when any data are in flight, without needing to manually track loading state per query. Then, when your data are available, and your component is “ready,” it’ll render. This is the subject that’s most commonly discussed with Suspense, and I’ve written about it previously; however, data loading is only one use case among many where Suspense can improve user experience. Another one I want to talk about today is image preloading.
Have you ever made, or used a web app where, after landing on a screen, your place on it staggers and jumps as images download and render? We call that content reflow and it can both be jarring and unpleasant. Suspense can help with this. You know how I said that Suspense is all about holding a component back from rendering until it’s ready? Fortunately, “ready” in this context is pretty open-ended — and for our purposes can included “images we need that have preloaded.” Let’s see how!
Quick crash course on Suspense
Before we dive into specifics, let’s take a quick look at how Suspense works. It has two main parts. The first is the concept of a component suspending. This means React attempts to render our component, but it’s not “ready.” When this happens, the nearest “fallback” in the component tree will render. We’ll look at making fallbacks shortly (it’s fairly straightforward), but the way in which a component tells React it’s not ready is by throwing a promise. React will catch that promise, realize the component isn’t ready, and render the fallback. When the promise resolves, React will again attempt to r.ender. Rinse, wash and repeat. Yes, I’m over-simplifying things a tad, but this is the gist of how Suspense works and we’ll expand on some of these concepts as we go.
The second part of Suspense is the introduction of “transition” state updates. This means we set state, but tell React that the state change may cause a component to suspend, and if this happens, to not render a fallback. Instead, we want to continue viewing the current screen, until the state update is ready, at which point it’ll render. And, of course, React provides us with a “pending” boolean indicator that lets the developer know this is in progress so we can provide inline loading feedback.
Let’s preload some images!
First off, I want to note that there’s a full demo of what we’re making at the end of this article. Feel free to open the demo now if you just want to jump into the code. It’ll show how to preload images with Suspense, combined with transition state updates. The rest of this post will build that code up step-by-step, explaining the how’s the why’s along the way.
OK, let’s go!
We want our component to suspend until all of its images have preloaded. To make things as simple as possible, let’s make a <SuspenseImage>
component that receives a src
attribute, preloads the image, handles the exception throwing, and then renders an <img>
when everything’s ready. Such a component would allow us to seamlessly drop our <SuspenseImage>
component wherever we want an image displayed, and Suspense would handle the grunt work of holding onto it until everything is ready.
We can start by making a preliminary sketch of the code:
const SuspenseImg = ({ src, ...rest }) => {
// todo: preload and throw somehow
return <img alt="" src={src} {...rest} />;
};
So we have two things to sort out: (1) how to preload an image, and (2) tying in exception throwing. The first part is pretty straightforward. We’re all used to using images in HTML via <img src="some-image.png">
but we can also create images imperatively using the Image()
object in JavaScript; moreover, images we create like this have an onload callback that fires when the image has … loaded. It looks like this:
const img = new Image();
img.onload = () => {
// image is loaded
};
But how do we tie that into exception throwing? If you’re like me, your first inclination might be something like this:
const SuspenseImg = ({ src, ...rest }) => {
throw new Promise((resolve) => {
const img = new Image();
img.onload = () => {
resolve();
};
});
return <img alt="" src={src} {...rest} />;
};
The problem, of course, is that this will always throw a promise. Every single time React attempts to render a <SuspenseImg>
instance, a new promise will be created, and promptly thrown. Instead, we only want to throw a promise until the image has loaded. There’s an old saying that every problem in computer science can be solved by adding a layer of indirection (except for the problem of too many layers of indirection) so let’s do just that and build an image cache. When we read a src,
the cache will check if it’s loaded that image, and if not, it’ll begin the preload, and throw the exception. And, if the image is preloaded, it’ll just return true and let React get on with rendering our image.
Here’s what our <SuspenseImage>
component looks like:
export const SuspenseImg = ({ src, ...rest }) => {
imgCache.read(src);
return <img src={src} {...rest} />;
};
And here’s what a minimal version of our cache looks like:
const imgCache = {
__cache: {},
read(src) {
if (!this.__cache[src]) {
this.__cache[src] = new Promise((resolve) => {
const img = new Image();
img.onload = () => {
this.__cache[src] = true;
resolve(this.__cache[src]);
};
img.src = src;
}).then((img) => {
this.__cache[src] = true;
});
}
if (this.__cache[src] instanceof Promise) {
throw this.__cache[src];
}
return this.__cache[src];
}
};
It’s not perfect, but it’s good enough for now. Let’s go ahead and put it to use.
The implementation
Remember, there’s a link to the fully working demo below, so if I move too fast at any particular step, don’t despair. We’ll explain things as well go.
Let’s start by defining our fallback. We define a fallback by placing a Suspense tag in our component tree, and pass our fallback via the fallback
prop. Any component which suspends will search upward for the nearest Suspense tag, and render its fallback (but if no Suspense tag is found, an error will be thrown). A real app would likely have many Suspense tags throughout, defining specific fallbacks for its various modules, but for this demo, we only need a single one wrapping our root app.
function App() {
return (
<Suspense fallback={<Loading />}>
<ShowImages />
</Suspense>
);
}
The <Loading>
component is a basic spinner, but in a real app, you’d likely want to render some sort of empty shell of the actual component you’re trying to render, to provide a more seamless experience.
With that in place, our <ShowImages>
component eventually renders our images with this:
<FlowItems>
{images.map(img => (
<div key={img}>
<SuspenseImg alt="" src={img} />
</div>
))}
</FlowItems>
On initial load, our loading spinner will show, until our initial images are ready, at which point they all show at once, without any staggered reflow jankiness.
Transition state update
Once the images are in place, when we load the next batch of them, we’d like to have them show up after they’ve loaded, of course, but keep the existing images on the screen while they load. We do this with the useTransition
hook. This returns a startTransition
function, and an isPending
boolean, which indicates that our state update is in progress, but has suspended (or even if it hasn’t suspended, may still be true if the state update is simply taking too long). Lastly, when calling useTransition
, you need to pass a timeoutMs
value, which is the maximum amount of time the isPending
flag can be true
, before React just gives up and renders the fallback (note, the timeoutMs
argument will likely be removed in the near future, with the transition state updates simply waiting as long as necessary when updating existing content).
Here’s what mine looks like:
const [startTransition, isPending] = useTransition({ timeoutMs: 10000 });
We’ll allow for 10 seconds to pass before our fallback shows, which is likely too long in real life, but is suitable for the purposes of this demo, especially when you might be purposefully slowing your network speed down in DevTools to experiment.
Here’s how we use it. When you click the button to load more images, the code looks like this:
startTransition(() => {
setPage(p => p + 1);
});
That state update will trigger a new data load using my GraphQL client micro-graphql-react, which, being Suspense-compatible, will throw a promise for us while the query is in flight. Once the data come back, our component will attempt to render, and suspend again while our images are preloading. While all of this is happening, our isPending
value will be true
, which will allow us to display a loading spinner on top of our existing content.
Avoiding network waterfalls
You might be wondering how React blocks rendering while image preloading is taking place. With the code above, when we do this:
{images.map(img => (
…along with our <SuspenseImage>
rendered therein, will React attempt to render the first image, Suspend, then re-attempt the list, get past the first image, which is now in our cache, only to suspend on the second image, then the third, fourth, etc. If you’ve read about Suspense before, you might be wondering if we need to manually preload all the images in our list before all this rendering occurs.
It turns out there’s no need to worry, and no need for awkward preloading because React is fairly smart about how it renders things in a Suspense world. As React is making its way through our component tree, it doesn’t just stop when it hits a suspension. Instead, it continues rendering all other paths through our component tree. So, yeah, when it attempts to render image zero, a suspension will occur, but React will continue attempting to render images 1 through N, and only then suspend.
You can see this in action by looking at the Network tab in the full demo, when you click the “Next images” button. You should see the entire bucket of images immediately show up in the network list, resolve one by one, and when all finished, the results should show up on screen. To really amplify this effect, you might want to slow your network speed down to “Fast 3G.”
For fun, we can force Suspense to waterfall over our images by manually reading each image from our cache before React attempts to render our component, diving through every path in the component tree.
images.forEach((img) => imgCache.read(img));
I created a demo that illustrates this. If you similarly look at the Network tab when a new set of images comes in, you’ll see them added sequentially in the network list (but don’t run this with your network speed slowed down).
Suspend late
There’s a corollary to keep in mind when using Suspense: suspend as late in the rendering and as low in the component tree as possible. If you have some sort of <ImageList>
which renders a bunch of suspending images, make sure each and every image suspends in its own component so React can reach it separately, and so none will block the others, resulting in a waterfall.
The data loading version of this rule is that data should be loaded as late as possible by the components that actually need it. That means we should avoid doing something like this in a single component:
const { data1 } = useSuspenseQuery(QUERY1, vars1);
const { data2 } = useSuspenseQuery(QUERY2, vars2);
The reason we want to avoid that is because query one will suspend, followed by query two, causing a waterfall. If this is simply unavoidable, we’ll need to manually preload both queries before the suspensions.
The demo
Here’s the demo I promised. It’s the same one I linked up above.
If you run it with your dev tools open, make sure you uncheck the box that says “Disable Cache” in the DevTools Network tab, or you’ll defeat the entire demo.
The code is almost identical to what I showed earlier. One improvement in the demo is that our cache read method has this line:
setTimeout(() => resolve({}), 7000);
It’s nice to have all our images preloaded nicely, but in real life we probably don’t want to hold up rendering indefinitely just because one or two straggling images are coming in slowly. So after some amount of time, we just give the green light, even though the image isn’t ready yet. The user will see an image or two flicker in, but it’s better than enduring the frustration of frozen software. I’ll also note that seven seconds is probably excessive, but for this demo, I’m assuming users might be slowing network speeds in DevTools to see Suspense features more clearly, and wanted to support that.
The demo also has a precache images checkbox. It’s checked by default, but you can uncheck it to replace the <SuspenseImage>
component with a regular ol’ <img>
tag, if you want to compare the Suspense version to “normal React” (just don’t check it while results are coming in, or the whole UI may suspend, and render the fallback).
Lastly, as always with CodeSandbox, some state may occasionally get out of sync, so hit the refresh button if things start to look weird or broken.
Odds and ends
There was one massive bug I accidentally made when putting this demo together. I didn’t want multiple runs of the demo to lose their effect as the browser caches images it’s already downloaded. So I manually modify all of the URLs with a cache buster:
const [cacheBuster, setCacheBuster] = useState(INITIAL_TIME);
const { data } = useSuspenseQuery(GET_IMAGES_QUERY, { page });
const images = data.allBooks.Books.map(
(b) => b.smallImage + `?cachebust=${cacheBuster}`
);
INITIAL_TIME
is defined at the modules level (i.e. globally) with this line:
const INITIAL_TIME = +new Date();
And if you’re wondering why I didn’t do this instead:
const [cacheBuster, setCacheBuster] = useState(+new Date());
…it’s because this does horrible, horrible things. On first render, the images attempt to render. The cache causes a suspension, and React cancels the render, and shows our fallback. When all of the promises have resolved, React will attempt this initial render anew, and our initial useState
call will re-run, which means that this:
const [cacheBuster, setCacheBuster] = useState(+new Date());
…will re-run, with a new initial value, causing an entirely new set of image URLs, which will suspend all over again, ad infinitum. The component will never run, and the CodeSandbox demo grinds to a halt (making this frustrating to debug).
This might seem like a weird one-off problem caused by a unique requirement for this particular demo, but there’s a larger lesson: rendering should be pure, without side effects. React should be able to re-attempt rendering your component any number of times, and (given the same initial props) the same exact state should come out the other end.
The post Pre-Caching Image with React Suspense appeared first on CSS-Tricks.
You can support CSS-Tricks by being an MVP Supporter.
source https://css-tricks.com/pre-caching-image-with-react-suspense/
No comments:
Post a Comment