Route-level Suspense with React Router
Using defer and Await to handle loading states
Route vs Component-level Suspense - what does our app need?
In my previous post, I addressed managing loading states at the component level using React Suspense. But it’s important to note that there are potentially other ways to implement our error and suspense boundaries, depending on the structure of our app and its data fetching patterns.
I originally talked about the 'render while fetching' pattern using React Suspense, which works well with splitting your data fetch requests and tying each request at the component level to the element that specifically needs that data. This way, you can run your data fetch requests concurrently, and load each element as its data becomes available - avoiding a series of blocking requests (network waterfall), or otherwise needing to await the return of ALL data before beginning to render any components.
Sometimes though, our ability to split data fetching in this way is limited by the structure of our app. In my case, I have an index page which makes a single network request (for all items in a specific collection), and then iterates through that data to render a series of card components.
The data is fetched separately using a React Router loader, which is tied to a route - when the user navigates to the index page, the request is made. This data is then used in the component with the useLoaderData()
hook:
export default function Index() {
const bathrooms = useLoaderData();
const addressBox = {
bg: "black",
display: "inline-block",
px: 2,
py: 1,
color: "white",
mb: 2,
};
return (
<>
<Box className="pageWrapper">
<Heading textAlign={"center"}>All bathrooms</Heading>
<Wrap justify={"center"}>
{bathrooms.map((bathroom, index) => (
<ErrorBoundary
fallback={<FallbackBathroomCard variation="index" />}
>
<Suspense fallback={<p>Loading...</p>}>
<BathroomCard
bathroom={bathroom}
variation="index"
key={index}
index={index}
>
<Box p={4}>
<Box sx={addressBox}>
<Text fontSize={"xs"} fontWeight="medium">
{bathroom.tags["addr:street"]}
</Text>
</Box>
<Heading color={"black"} fontSize={"2xl"} noOfLines={3}>
{bathroom.tags.name}
</Heading>
<Text color={"gray.500"} noOfLines={2}>
{bathroom.tags.description}
</Text>
</Box>
<Box p={4}>
<Text color={"gray.500"} noOfLines={4}>
{bathroom.tags.opening_hours}
</Text>
</Box>
</BathroomCard>
</Suspense>
</ErrorBoundary>
))}
</Wrap>
</Box>
</>
);
}
There’s a limitation here - note that I have Suspense and Error boundaries around each card component. But since the data fetching has already occurred, these boundaries won't actually catch/handle the loading and error states related to my data fetching.
We can’t simply wrap the .map()
function with these boundaries, because we’ll now lose the ability to handle suspense/errors for each individual component.
So how do we manage this within our current app’s structure? We have a couple of options:
One option would be to restructure things by splitting my requests, so that each card fetches its own data individually. Any loading or error states will therefore be handled at the component level, and caught by each card's suspense/error boundary. But this potentially creates a lot of network requests (N number of network requests (one for each card), which can become inefficient.
Another, arguably better approach here, is to modify our data fetching so that we can provide a suspended loading state for our entire page (while also still providing a fallback for each individual card, if we wish).
We’ll be going with option 2, which can work well with fetching data at the route level. To allow this we’ll need to work with Suspense-enabled data, which I cover next.
Working with Promises
First, a thing to note about how we actually handle data fetching, which is tied to Promises.
Previously we looked at different data fetching patterns, and how these work with React Suspense. But we didn't go into much depth regarding how our asynchronous data fetching is actually being handled.
As the React docs note, only Suspense-enabled data sources will activate the Suspense component: https://react.dev/reference/react/Suspense. What this means is that <Suspense>
doesn't work with a standard fetch request. What are Suspense-enabled data sources? They include:
Data fetching with Suspense-enabled frameworks like Relay and Next.js
Lazy-loading component code with https://react.dev/reference/react/lazy
Reading the value of a Promise with https://react.dev/reference/react/use
React Router's loaders: https://reactrouter.com/en/main/routers/picking-a-router
Suspense does not detect when data is fetched inside an Effect or event handler.
Why? Suspense
works with promises that are designed to "suspend" the rendering of a component until they resolve - so it requires a specific kind of promise that can be managed by a resource like a deferred
resource or a library that supports suspending (i.e. handling loading states when the Promise is pending).
You can either handle this yourself, or the more sensible (and recommended) approach is to use a library or framework.
This is where React Router comes in (as one of a number of options).
React Router
Setting up React Router for data APIs
Note that you need to use React Router v6.4 or later, which introduced the new data APIs, including loaders that can work with Suspense. This means your setup will look slightly different to a traditional React Router setup - as the docs mention, this only works with a data router: https://reactrouter.com/en/main/route/loader#loader
Instead of the ready-made <BrowserRouter>
component you may have used previously, we now use the <RouterProvider>
and pass a custom-made router into the provider, which we create using the createBrowserRouter()
method:
import {
createBrowserRouter,
createRoutesFromElements,
Route,
RouterProvider,
} from "react-router-dom";
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<Root />}>
<Route path="dashboard" element={<Dashboard />} />
{/* ... etc. */}
</Route>
)
);
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
Our React Router setup now works with the data APIs, which enables us to use Suspense, as well as other React Router features which will come in handy - such as <Form>
, and route.lazy
. A full list is here: https://reactrouter.com/en/main/routers/picking-a-router#data-apis
Handling data with defer
, Suspense
and Await
Now onto our data handling.
To properly manage your data fetching, you can introduce defer
and Await
from React Router. These allow us to work with the state of our Promises:
defer
allows you to mark parts of the data as deferred, meaning they will be resolved later, and React Suspense will handle our fallback loading states while the data is fetched asynchronously. https://reactrouter.com/en/main/utils/defer<Await>
is used to handle rendering once the deferred data has resolved, often inside aSuspense
boundary: https://reactrouter.com/en/main/components/await
Let’s look at how this changes our code.
Previously, I used fetch
within my React Router loader to collect my data, and then returned the parsed result of this using .json()
. Here's my original data loader:
export default async function indexLoader() {
try {
const res = await fetch("http://localhost:8000/bathrooms");
if (!res.ok) {
throw new Error(`Error fetching bathrooms: ${res.status} ${res.statusText}`);
}
const data = await res.json();
return data;
} catch (error) {
throw new Error(`Network error: ${error.message}`);
}
}
(While the validation here has been kept simple, it's probably worth mentioning that I also use Express middleware to handle server-side validation of async requests - I would recommend this).
When React Router's useLoaderData()
hook is called in your component, the data is already loaded before the component renders. This means that the Promise is already fulfilled:
const bathrooms = useLoaderData();
Here, the 'bathrooms' variable represents a fulfilled Promise. In the current setup, there's no "pending promise" or delay to suspend the component rendering.
Now using defer
, we can handle an unresolved Promise, which we pass to our components. It's implemented like this:
import { defer } from "react-router-dom";
export default async function indexLoader() {
try {
const res = await fetch("http://localhost:8000/bathrooms");
if (!res.ok) {
throw new Error(
`Error fetching bathrooms: ${res.status} ${res.statusText}`
);
}
const bathrooms = await res.json();
return defer({ bathrooms });
} catch (error) {
if (error instanceof Error) {
throw new Error(`Network error: ${error.message}`);
} else {
throw new Error("An unknown error occured.");
}
}
}
Our deferred response will now be a Promise, which allows us to handle it in its unfulfilled state. When we call this in our component, we therefore treat it as:
const { bathrooms } = useLoaderData(); // bathrooms is now a Promise
We can then enclose our data-dependent component(s) in a <Suspense>
boundary, which handles the loading state with a fallback
element. This in turn encloses our <Await>
boundary, which takes in:
A
resolve
prop, which handles the data once the Promise is resolved.An
errorElement
prop, which takes in a fallback component in the event that the Promise is rejected.
Combining boundaries in this way gives us a fallback for cases where data fetching might fail, or take some time:
export default function Index() {
const { bathrooms } = useLoaderData(); // bathrooms is now a Promise
const addressBox = {
bg: "black",
display: "inline-block",
px: 2,
py: 1,
color: "white",
mb: 2,
};
return (
<Box className="pageWrapper">
<Heading textAlign={"center"}>All bathrooms</Heading>
<Wrap justify={"center"}>
<Suspense fallback={<p>Loading all bathrooms...</p>}>
<Await
resolve={bathrooms}
errorElement={<p>Error loading bathrooms...</p>} // Handle global error
>
{(loadedBathrooms) =>
loadedBathrooms.map((bathroom, index) => (
<ErrorBoundary
fallback={<FallbackBathroomCard variation="index" />}
key={index}
>
<Suspense fallback={<BathroomCardSkeleton />}>
<BathroomCard
bathroom={bathroom}
variation="index"
index={index}
>
<Box p={4}>
<Box sx={addressBox}>
<Text fontSize={"xs"} fontWeight="medium">
{bathroom.tags["addr:street"]}
</Text>
</Box>
<Heading color={"black"} fontSize={"2xl"} noOfLines={3}>
{bathroom.tags.name}
</Heading>
<Text color={"gray.500"} noOfLines={2}>
{bathroom.tags.description}
</Text>
</Box>
<Box p={4}>
<Text color={"gray.500"} noOfLines={4}>
{bathroom.tags.opening_hours}
</Text>
</Box>
</BathroomCard>
</Suspense>
</ErrorBoundary>
))
}
</Await>
</Suspense>
</Wrap>
</Box>
);
}
And there we have it - Suspense-enabled, route-level data fetching, thanks to React Router!
Lazy loading
An extra note on lazy loading.
As we've seen, the data fetching pattern we've used here is at the route level. React Router loaders are designed to handle the fetch once, render separately pattern.
At the start of this article though, I mentioned that I wanted to render individual card components and give each a <Suspense>
boundary, so that we can have a more granular handling of loading states, such as a 'skeleton' fallback before each card loads in.
At first glance this approach may seem a little redundant. If our deferred data fetching is already resolved at the parent level (i.e. for all cards, before rendering them), why would we then need to worry about further loading states for each separate card component?
In this case, the primary benefit of handling suspense for individual components, is actually tied to rendering.
I mentioned at the start that Suspense can help with rendering issues, which may become more apparent as more components are loaded. But if we want to truly make use of a 'deferred' state for rendering, we can make use of lazy loading.
Implementation is very straightforward - instead of simply importing our component, we can first import the lazy()
method from React, and then pass in our import:
import { Suspense, lazy } from "react";
const BathroomCard = lazy(() => import("../components/BathroomCard"));
That's it!
Now when we render our card components, not only is our loading state connected with a deferred rendering process, but we will likely see some performance benefits - our initial bundle size should be smaller, and we may see improved load times, especially if rendering a large number of items.
I'll show more on lazy loading (at the route and component level) another time.