DEV Community

loading...
Cover image for Understanding render-as-you-fetch with React & Relay

Understanding render-as-you-fetch with React & Relay

Ben Taylor
G'day
Updated on ・8 min read

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} />
  </>)
}
Enter fullscreen mode Exit fullscreen mode

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:

Waterfall Loading

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>
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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} />
  </>)
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Nested routes - you can't know what data you'll need at each level until you evaluate the routes

  2. Concurrent mode - your data-loading could be inside a component that has paused rendering

  3. Slow components - the performance of your data loading is dependent on how fast your components evaluate

  4. 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>)
}
Enter fullscreen mode Exit fullscreen mode

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, /*...*/)
Enter fullscreen mode Exit fullscreen mode

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>)
}
Enter fullscreen mode Exit fullscreen mode

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} />
  </>)
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Preloading data with preloadQuery
  2. 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} />)
}
Enter fullscreen mode Exit fullscreen mode

And our routes would look like:

<Router>
    <Header />
    <Switch>
        <Route path="/profile/:profileId">
            <ProfileEntrypoint />
        </Route>
    </Switch>
</Router>
Enter fullscreen mode Exit fullscreen mode

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}),
          }
        },
      },
    ],
  },
]
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (1)

Collapse
jonathansewell profile image
Johnathan Sewell

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...