I've been moving an existing codebase to a GraphQL API over the last few weeks using Relay as the front-end client. One thing I've been struggling with has been implementing the render-as-you-fetch (or fetch-as-you-render) pattern. A big part of the difficulty here is how our tools rely on the render path for coordinating work. I'm using this article as a way to write down what I've learned researching and figuring out this pattern in practice.
What is render-as-you-fetch?
I'm not sure about the origin of the idea, but there's a great explanation of it in the ReactConf 2019 demo of Relay. There's also some good explanations in the React Docs for Suspense.
The basic idea is that the render path of your components is a bad place to load data. The simplest reason is that it can be blocked by other components loading. If you only load data on the render path, you can be susceptible to waterfalls of loads. The worst case is one component blocks a number of other components from rendering, then when it unblocks them all of those components need to load their own data.
Imagine a profile page for a user:
function ProfilePage({ userId }) {
const [isLoaded, profileData] = useProfileDataFetcher(userId)
if (!isLoaded) {
return <LoadingSpinner />
}
return (<>
<ProfileHeader profile={profileData} />
<PhotoCarousel photoIds={profileData.recentPhotoIds} />
<PostList postIds={profileData.recentPostIds} />
</>)
}
You could imagine that the PhotoCarousel
component and the PostList
component both need to go get their own data. So you have one fetch (the profile data) blocking two more fetches. Each of those components could also be fetching data, such as comments, avatars etc. This creates a cascade of loading symbols like:
When the first component finishes loading, it reveals its dependent child components - which of course now need to load!
These waterfalls show a real flaw in the pattern of loading data inside a component (on the render path). It creates an awkward UX and makes your page much slower to load (even if your individual components are quite performant).
An aside on Suspense for Data Loading
To fully grasp the render-as-you-fetch pattern you also need to understand how Suspense for Data Loading works. It's a really nifty pattern that works kind of like an Error Boundary. You set it up by creating a Suspense
component with a fallback loading component:
<Suspense fallback={<LoadingSpinner />}>
<ProfilePage />
</Suspense>
Then if a component starts rendering, but is not yet ready to render you throw
a Promise
that will resolve when it's ready. To use it in our example we could modify our useFetchProfileData
hook to throw if the data isn't finished loading.
const profileFetcher = new ProfileDataFetcher()
function useProfileDataFetcher(userId) {
profileFetcher.loadFromNetworkOrCache(userId)
if (profileFetcher.isLoading(userId)) {
throw profileFetcher.getPromise(userId)
}
return profileFetcher.getData(userId)
}
The Promise that we throw then gets waited on by the Suspense
component until it's complete. In its place the LoadingSpinner
is rendered. Once it's complete the component will continue rendering.
A neat result of this, is that we don't need to handle managing loading state within our component. Instead we can assume we always have the data we depend on. This simplifies our ProfilePage
quite a bit:
function ProfilePage({ userId }) {
const profileData = useProfileDataFetcher(userId)
return (<>
<ProfileHeader profile={profileData} />
<PhotoCarousel photoIds={profileData.recentPhotoIds} />
<PostList postIds={profileData.recentPostIds} />
</>)
}
But it doesn't stop our waterfall cascade of loading spinners.
Back to our Waterfall
The simplest solution to this problem would be to fetch all of the nested data in the ProfilePage
component at once. The ProfilePage
would load the profile data, the photos, the posts, the usernames etc. But this breaks down in a number of situations:
Nested routes - you can't know what data you'll need at each level until you evaluate the routes
Concurrent mode - your data-loading could be inside a component that has paused rendering
Slow components - the performance of your data loading is dependent on how fast your components evaluate
Re-rendering - each time your component is rendered it needs to retry fetching the data, even if it's unnecessary (e.g. a theme change)
The solution to all of these problems is render-as-you-fetch. Instead of putting the fetching code inside of your component, you put it outside the component, and make sure it happens before the render even occurs. Imagine something like:
function ProfileButton({ userId, name }) {
const router = useRouter()
const clickAction = function() {
profileFetcher.load(userId)
router.navigateToProfilePage(userId)
}
return (<button onClick={clickAction}>{ name }</button>)
}
When the button is clicked the clickAction
first loads the profile data, and then triggers navigation. This way the loading happens not only before the ProfilePage
starts loading, but it happens outside of the render path. So complicated render logic has no way of impacting when the data gets loaded.
In relay this is all achieved using two hooks:
// From a container
const [queryRef, loadQuery] = useQueryLoader(/*...*/)
// Inside your component
const data = usePreloadedQuery(queryRef, /*...*/)
The first provides us with a loadQuery
function that can be called to start the query loading, and a queryRef
that will refer to that state. The second takes the queryRef
and returns the data - or suspends if it hasn't loaded yet. There's also a less safe loadQuery
function provided by Relay that doesn't automatically dispose of data.
Our ProfileButton
example above, when using Relay would become something like:
function ProfileButton({ userId, name }) {
const router = useRouter()
const [queryRef, loadQuery] = useQueryLoader(/*...*/)
const clickAction = function() {
loadQuery(/*...*/, {userId})
router.navigateToProfilePage(queryRef)
}
return (<button onClick={clickAction}>{ name }</button>)
}
And our Profile
component would look like:
function ProfilePage({ queryRef }) {
const profileData = usePreloadedQuery(queryRef, /*...*/)
return (<>
<ProfileHeader profile={profileData} />
<PhotoCarousel photos={profileData.recentPhotos} />
<PostList posts={profileData.recentPosts} />
</>)
}
Here the queryRef
is passed on to the ProfilePage
so that it has a handle for the data loading. Then the usePreloadedQuery
call will suspend if the data is still loading.
Routing with render-as-you-fetch
The big difficulty with all of this is that it starts falling apart when you consider routing. If you trigger fetching just before a navigation (like in the above example) what happens if the user visits that route directly? It would fail to load, because the queryRef
hasn't been created.
In the ReactConf 2019 Relay demo video that I linked earlier they solve this with a thing called an "Entrypoint". This is a concept that wraps up two tasks together:
- Preloading data with
preloadQuery
- Retrieving the
lazy
component for the route
In this case the idea is that each routing entrypoint contains a helper for loading its data, and it uses webpack codesplitting for lazy-loading each route's component hierarchy.
Using react-router
attempting this approach, the entrypoint would look something like:
const Profile = lazy(() => import('./Profile'))
export function ProfileEntrypoint() {
const { profileId } = useParams();
const [queryRef, loadQuery] = useQueryLoader(/*...*/, { profileId })
loadQuery()
return (<Profile queryRef={queryRef} />)
}
And our routes would look like:
<Router>
<Header />
<Switch>
<Route path="/profile/:profileId">
<ProfileEntrypoint />
</Route>
</Switch>
</Router>
But this isn't going to work!
Unfortunately we've violated one of the rules we created going in: we've put the data fetching on the render path. Because our entrypoint is a component, and we call loadQuery
when the component renders, the loading happens in the render path.
Our fundamental problem here is that the routing paths are evaluated during render, and not when the history object triggers a change. From what I understand it doesn't seem like its possible to resolve this. That means react-router
is out. So is any router that evaluates its routes through components!
Finding a suitable router
So now we need to find a suitable router that can support this pattern of requesting data outside of the render path. The relay community has built an extension to Found - but it hasn't been updated for render-as-you-fetch. The Found router itself is quite flexible and extensible and so you could potentially implement entrypoints on top, but I haven't seen an example of this. As for other routers, I haven't seen any that aren't taking the react-router
approach.
It seems like this is a problem that the relay
team have seen in advance. Their Issue Tracker example rolls its own routing system based off the same primitives used by react-router
.
There's also a couple of routers that people have built after encountering this problem: React Suspense Router and Pre-Router. Both are not very mature, but are promising. Pre-router particularly is quite clearly inspired by the Issue Tracker example.
Since they are rather immature, I think right now the best idea is to use the Router in the Issue Tracker example and maintain it yourself. This isn't a great solution, but it seems to be the only way forward for now.
Using the routing system from that example, our routes from before would instead look something like:
const routes = [
{
component: JSResource('Root', () => import('./Root')),
routes: [
/* ... */
{
path: '/profile/:id',
component: JSResource('Profile', () =>
import('./Profile'),
),
prepare: params => {
return {
queryRef: loadQuery(/* ... */, {id: params.id}),
}
},
},
],
},
]
Here we see the entrypoint pattern quite clearly. Each route is made up of a path to match, a component to fetch, and a prepare function that loads the appropriate query. The JSResource
helper here will cache the returned component to make sure it doesn't get lazily requested multiple times. While the prepare
function is used to trigger any preparation work for the route - in our case that's the loadQuery
function that Relay provides.
What's particularly useful about this approach is how loading works with nested routes. Each of the nested routes will be matched all at once, and their prepare calls and components will be successively run. Once all the preparation work is done the rendering can start, and even if rendering blocks at a higher level the data has already started loading for the lower levels. Waterfall solved!
Wrapping up
So that resolves our problem! But it does mean a lot of extra work for me, replacing our existing routing system with one that supports this new paradigm.
I hope this has helped you understand the render-as-you-fetch pattern, and helped you see how it might be implemented in practice using relay. If you know of a better solution to the routing problem, I'd love to hear it in the comments. Understanding all of this has been a bit of a wild ride for me, and I'm still getting my head around each of the required components. What seems like a straightforward idea at first ends up being more than a little complex.
Edit: Max Wheeler recommended on twitter that I check out Atlassian's React Resource Router. It looks like a great solution for render-as-you-fetch for regular fetch requests, however its API isn't ideal for relay. It might work with some nice wrappers around its useResource
method. Worth checking out!
Edit2: @gajus has recommended using YARR (github.com/contra/yarr) which seems to be a great solution to this problem.
Top comments (6)
github.com/contra/yarr is another option, built from the ground up to be a replacement for react-router with relay support
This looks like a great solution, thanks heaps for commenting!
I'm currently working on a project with React + Relay and looking for a routing solution. I think react-location (react-location.tanstack.com/) might by another option to explore. I think they have a similar approach that might work with relay by calling the queries before render through the loader prop.
Well done on an informative article. It may be worth considering the point in this somewhat outdated Relay guide about not using the router if you have a very flat route tree relay.dev/docs/v10.1.3/routing/#fl...
React router v6 allows you to load before rendering components
reactrouter.com/en/main/route/loader
It's one of the "killer feature" of react router v6
what do you think about react-router. It has loader function for each route