Deferred Data
Sometimes you want to retrieve some optional data without blocking the rest of the page that contains the critical data of your application. Examples of this optional data are comments on a blog post that render after the main content, recommended products on a shopping cart, recent searches, etc.
React Router 6 introduced the "deferred" API that allows you to "await" for critical data and "defer" optional data when calling your loaders.
The best part is that you can switch between one mode or the other by just adding or removing the await keyword from the promise that resolves the data. Ryan Florence gave an excellent explanation of this mechanism in his talk "When to Fetch" (seriously, it is an amazing talk, If you haven't watched, bookmark it and watch it after you have finished reading this post!)
I took a look at the deferred demo app from the React Router examples folder and the documentation to discover all its potential, however, I couldn't find an example with fetch
so I decided to give it a go and play around with it, here is what I found.
Using defer
with Fetch
I created a mock server with MSW to simulate a fetch delay as well as display the same text from the example.
Here is my first naive attempt:
// Don't copy paste! bad code! keep on reading...
export const loader = async () => {
return defer({
critical1: await fetch('/test?text=critical1&delay=250').then(res => res.json()),
critical2: await fetch('/test?text=critical2&delay=500').then(res => res.json()),
lazyResolved: fetch('/test?text=lazyResolved&delay=0').then(res => res.json()),
lazy1: fetch('/test?text=lazy1&delay=1000').then(res => res.json()),
lazy2: fetch('/test?text=lazy2&delay=1500').then(res => res.json()),
lazy3: fetch('/test?text=lazy3&delay=2000').then(res => res.json()),
lazyError: fetch('/test?text=lazy3&delay=2500').then(res => {
throw Error('Oh noo!')
}),
});
}
So what are we doing here?
First, returning a "naked fetch" from a normal loader works because that's what loaders expect and React Router will unwrap the response for you, however, defer
accepts values or promises that resolve to values, so to get the values we need to unwrap the fetch response manually.
Fetch is great! however, you have to do some additional work, including throwing errors and unwrapping promises as we have done above. Use the platform yay! 😅
Here is the result:
It all looked great until I opened the network tab and something didn't look quite right 🤔
The first two requests for critical data that use the await
keyword are happening in a waterfall, not in parallel like the rest! in fact, if you add await to all the calls in the defer object they all happen in a waterfall, what gives!
Did Ryan lie to us?
Is this a bug? 🐛
Does critical data need to happen in a waterfall? 🚿
Nope!, of course not! it turns out that I forgot how Javascript works™️
The waterfall occurs because every time you create a separate await
, it will pause execution before continuing to the next line and before firing the next fetch.
What we want to do to avoid the waterfall is fire all those fetch requests at the same time and only await
for the response not the actual fetch
.
“The earlier you initiate a fetch, the better, because the sooner it starts, the sooner it can finish”
— @TkDodo
To achieve this we can declare and fire all the fetch requests and then add the await
for critical data in the defer object later.
// You can copy this one if you want
export const loader = async () => {
// fire them all at once
const critical1Promise = fetch('/test?text=critical1&delay=250').then(res => res.json());
const critical2Promise = fetch('/test?text=critical2&delay=500').then(res => res.json());
const lazyResolvedPromise = fetch('/test?text=lazyResolved&delay=100').then(res => res.json());
const lazy1Promise = fetch('/test?text=lazy1&delay=500').then(res => res.json());
const lazy2Promise = fetch('/test?text=lazy2&delay=1500').then(res => res.json());
const lazy3Promise = fetch('/test?text=lazy3&delay=2500').then(res => res.json());
const lazyErrorPromise = fetch('/test?text=lazy3&delay=3000').then(res => {
throw Error('Oh noo!')
});
// await for the response
return defer({
critical1: await critical1Promise,
critical2: await critical2Promise,
lazyResolved: lazyResolvedPromise,
lazy1: lazy1Promise,
lazy2: lazy2Promise,
lazy3: lazy3Promise,
lazyError: lazyErrorPromise
})
}
You can also do Promise.all()
but with the above example, it is easier to understand what's going on.
Here is what the network tab looks like now, beautiful parallel green bars.
Now that we fixed the waterfall, let's play around with the delay and explore a couple of interesting features of defer:
Critical Data
The critical data uses the 'await' keyword so the loader and React Router will wait until the data is ready before the first render (no loading spinners 🎉).
What happens if critical data (using await) returns an error? 🤔 well, the loader will throw and bubble up to the nearest error boundary and destroy the entire page or that entire route segment.
If you want to fail gracefully and don't want to destroy the entire page then remove await which is basically telling React Router, hey! I don't care if this data fails, it is not that important (critical) so display a localised error boundary instead. That's exactly what the lazyError
is doing in the first example.
Lazy Resolved
We are not using an await
on the lazyResolved
field, however we don't see a loading spinner at all. How is that? This is actually an amazing feature of defer, if your optional data is fast (faster than your critical data) then you won't see a spinner at all because your promise will be resolved by the time the critical data finishes and the first render occurs:
The slowest critical data delay is 500ms
but the lazyResolved
data takes only 100ms
so by the time critical2
is resolved, the lazyResolved
promise has already been resolved and the data is immediately available.
The best thing about defer is that you don't have to choose how to fetch your data, it will display optional data immediately if it is fast or shows a loading spinner if it is slow.
You can play around changing the delays and increasing/reducing the time to see if the spinners are shown or not. For example, if we increase the delay for the lazyResolved
to 3500ms
we will see a loading spinner.
Conclusion
Defer is a great API, it took me a while to understand it and make it work with fetch but it is an amazing tool to improve performance, reliability of your pages and developer experience.
The Source code for the examples is available here:
Top comments (3)
Awesome post, super clear, love the examples of traps and how to debug them!
Hey thanks Jesse, glad it helped!
Succinct and well explained. Good job, Ruben 🌟