Prefetching & Router Integration

· abundance's blog


When you know or suspect that a certain piece of data will be needed, you can use prefetching to populate the cache with that data ahead of time, leading to a faster experience.

There are a few different prefetching patterns:

  1. In event handlers
  2. In components
  3. Via router integration
  4. During Server Rendering (another form of router integration)

In this guide, we'll take a look at the first three, while the fourth will be covered in depth in the Server Rendering & Hydration guide and the Advanced Server Rendering guide.

One specific use of prefetching is to avoid Request Waterfalls, for an in-depth background and explanation of those, see the Performance & Request Waterfalls guide.

prefetchQuery & prefetchInfiniteQuery #

Before jumping into the different specific prefetch patterns, let's look at the prefetchQuery and prefetchInfiniteQuery functions. First a few basics:

This is how you use prefetchQuery:

1const prefetchTodos = async () => {
2  // The results of this query will be cached like a normal query
3  await queryClient.prefetchQuery({
4    queryKey: ['todos'],
5    queryFn: fetchTodos,
6  })
7}

Infinite Queries can be prefetched like regular Queries. Per default, only the first page of the Query will be prefetched and will be stored under the given QueryKey. If you want to prefetch more than one page, you can use the pages option, in which case you also have to provide a getNextPageParam function:

 1const prefetchProjects = async () => {
 2  // The results of this query will be cached like a normal query
 3  await queryClient.prefetchInfiniteQuery({
 4    queryKey: ['projects'],
 5    queryFn: fetchProjects,
 6    initialPageParam: 0,
 7    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
 8    pages: 3, // prefetch the first 3 pages
 9  })
10}

Next, let's look at how you can use these and other ways to prefetch in different situations.

Prefetch in event handlers #

A straightforward form of prefetching is doing it when the user interacts with something. In this example we'll use queryClient.prefetchQuery to start a prefetch on onMouseEnter or onFocus.

 1function ShowDetailsButton() {
 2  const queryClient = useQueryClient()
 3
 4  const prefetch = () => {
 5    queryClient.prefetchQuery({
 6      queryKey: ['details'],
 7      queryFn: getDetailsData,
 8      // Prefetch only fires when data is older than the staleTime,
 9      // so in a case like this you definitely want to set one
10      staleTime: 60000,
11    })
12  }
13
14  return (
15    <button onMouseEnter={prefetch} onFocus={prefetch} onClick={...}>
16      Show Details
17    </button>
18  )
19}

Prefetch in components #

Prefetching during the component lifecycle is useful when we know some child or descendant will need a particular piece of data, but we can't render that until some other query has finished loading. Let's borrow an example from the Request Waterfall guide to explain:

 1function Article({ id }) {
 2  const { data: articleData, isPending } = useQuery({
 3    queryKey: ['article', id],
 4    queryFn: getArticleById,
 5  })
 6
 7  if (isPending) {
 8    return 'Loading article...'
 9  }
10
11  return (
12    <>
13      <ArticleHeader articleData={articleData} />
14      <ArticleBody articleData={articleData} />
15      <Comments id={id} />
16    </>
17  )
18}
19
20function Comments({ id }) {
21  const { data, isPending } = useQuery({
22    queryKey: ['article-comments', id],
23    queryFn: getArticleCommentsById,
24  })
25
26  ...
27}

This results in a request waterfall looking like this:

1. |> getArticleById()
2.   |> getArticleCommentsById()

As mentioned in that guide, one way to flatten this waterfall and improve performance is to hoist the getArticleCommentsById query to the parent and pass down the result as a prop, but what if this is not feasible or desirable, for example when the components are unrelated and have multiple levels between them?

In that case, we can instead prefetch the query in the parent. The simplest way to do this is to use a query but ignore the result:

 1function Article({ id }) {
 2  const { data: articleData, isPending } = useQuery({
 3    queryKey: ['article', id],
 4    queryFn: getArticleById,
 5  })
 6
 7  // Prefetch
 8  useQuery({
 9    queryKey: ['article-comments', id],
10    queryFn: getArticleCommentsById,
11    // Optional optimization to avoid rerenders when this query changes:
12    notifyOnChangeProps: [],
13  })
14
15  if (isPending) {
16    return 'Loading article...'
17  }
18
19  return (
20    <>
21      <ArticleHeader articleData={articleData} />
22      <ArticleBody articleData={articleData} />
23      <Comments id={id} />
24    </>
25  )
26}
27
28function Comments({ id }) {
29  const { data, isPending } = useQuery({
30    queryKey: ['article-comments', id],
31    queryFn: getArticleCommentsById,
32  })
33
34  ...
35}

This starts fetching 'article-comments' immediately and flattens the waterfall:

1. |> getArticleById()
1. |> getArticleCommentsById()

If you want to prefetch together with Suspense, you will have to do things a bit differently. You can't use useSuspenseQueries to prefetch, since the prefetch would block the component from rendering. You also can not use useQuery for the prefetch, because that wouldn't start the prefetch until after suspenseful query had resolved. For this scenario, you can use the usePrefetchQuery or the usePrefetchInfiniteQuery hooks available in the library.

You can now use useSuspenseQuery in the component that actually needs the data. You might want to wrap this later component in its own <Suspense> boundary so the "secondary" query we are prefetching does not block rendering of the "primary" data.

 1function App() {
 2  usePrefetchQuery({
 3    queryKey: ['articles'],
 4    queryFn: (...args) => {
 5      return getArticles(...args)
 6    },
 7  })
 8
 9  return (
10    <Suspense fallback="Loading articles...">
11      <Articles />
12    </Suspense>
13  )
14}
15
16function Articles() {
17  const { data: articles } = useSuspenseQuery({
18    queryKey: ['articles'],
19    queryFn: (...args) => {
20      return getArticles(...args)
21    },
22  })
23
24  return articles.map((article) => (
25    <div key={articleData.id}>
26      <ArticleHeader article={article} />
27      <ArticleBody article={article} />
28    </div>
29  ))
30}

Another way is to prefetch inside of the query function. This makes sense if you know that every time an article is fetched it's very likely comments will also be needed. For this, we'll use queryClient.prefetchQuery:

 1const queryClient = useQueryClient()
 2const { data: articleData, isPending } = useQuery({
 3  queryKey: ['article', id],
 4  queryFn: (...args) => {
 5    queryClient.prefetchQuery({
 6      queryKey: ['article-comments', id],
 7      queryFn: getArticleCommentsById,
 8    })
 9
10    return getArticleById(...args)
11  },
12})

Prefetching in an effect also works, but note that if you are using useSuspenseQuery in the same component, this effect wont run until after the query finishes which might not be what you want.

1const queryClient = useQueryClient()
2
3useEffect(() => {
4  queryClient.prefetchQuery({
5    queryKey: ['article-comments', id],
6    queryFn: getArticleCommentsById,
7  })
8}, [queryClient, id])

To recap, if you want to prefetch a query during the component lifecycle, there are a few different ways to do it, pick the one that suits your situation best:

Let's look at a slightly more advanced case next.

Dependent Queries & Code Splitting #

Sometimes we want to prefetch conditionally, based on the result of another fetch. Consider this example borrowed from the Performance & Request Waterfalls guide:

 1// This lazy loads the GraphFeedItem component, meaning
 2// it wont start loading until something renders it
 3const GraphFeedItem = React.lazy(() => import('./GraphFeedItem'))
 4
 5function Feed() {
 6  const { data, isPending } = useQuery({
 7    queryKey: ['feed'],
 8    queryFn: getFeed,
 9  })
10
11  if (isPending) {
12    return 'Loading feed...'
13  }
14
15  return (
16    <>
17      {data.map((feedItem) => {
18        if (feedItem.type === 'GRAPH') {
19          return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />
20        }
21
22        return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />
23      })}
24    </>
25  )
26}
27
28// GraphFeedItem.tsx
29function GraphFeedItem({ feedItem }) {
30  const { data, isPending } = useQuery({
31    queryKey: ['graph', feedItem.id],
32    queryFn: getGraphDataById,
33  })
34
35  ...
36}

As noted over in that guide, this example leads to the following double request waterfall:

1. |> getFeed()
2.   |> JS for <GraphFeedItem>
3.     |> getGraphDataById()

If we can not restructure our API so getFeed() also returns the getGraphDataById() data when necessary, there is no way to get rid of the getFeed->getGraphDataById waterfall, but by leveraging conditional prefetching, we can at least load the code and data in parallel. Just like described above, there are multiple ways to do this, but for this example, we'll do it in the query function:

 1function Feed() {
 2  const queryClient = useQueryClient()
 3  const { data, isPending } = useQuery({
 4    queryKey: ['feed'],
 5    queryFn: async (...args) => {
 6      const feed = await getFeed(...args)
 7
 8      for (const feedItem of feed) {
 9        if (feedItem.type === 'GRAPH') {
10          queryClient.prefetchQuery({
11            queryKey: ['graph', feedItem.id],
12            queryFn: getGraphDataById,
13          })
14        }
15      }
16
17      return feed
18    }
19  })
20
21  ...
22}

This would load the code and data in parallel:

1. |> getFeed()
2.   |> JS for <GraphFeedItem>
2.   |> getGraphDataById()

There is a tradeoff however, in that the code for getGraphDataById is now included in the parent bundle instead of in JS for <GraphFeedItem> so you'll need to determine what's the best performance tradeoff on a case by case basis. If GraphFeedItem are likely, it's probably worth to include the code in the parent. If they are exceedingly rare, it's probably not.

Router Integration #

Because data fetching in the component tree itself can easily lead to request waterfalls and the different fixes for that can be cumbersome as they accumulate throughout the application, an attractive way to do prefetching is integrating it at the router level.

In this approach, you explicitly declare for each route what data is going to be needed for that component tree, ahead of time. Because Server Rendering has traditionally needed all data to be loaded before rendering starts, this has been the dominating approach for SSR'd apps for a long time. This is still a common approach and you can read more about it in the Server Rendering & Hydration guide.

For now, let's focus on the client side case and look at an example of how you can make this work with Tanstack Router. These examples leave out a lot of setup and boilerplate to stay concise, you can check out a full React Query example over in the Tanstack Router docs.

When integrating at the router level, you can choose to either block rendering of that route until all data is present, or you can start a prefetch but not await the result. That way, you can start rendering the route as soon as possible. You can also mix these two approaches and await some critical data, but start rendering before all the secondary data has finished loading. In this example, we'll configure an /article route to not render until the article data has finished loading, as well as start prefetching comments as soon as possible, but not block rendering the route if comments haven't finished loading yet.

 1const queryClient = new QueryClient()
 2const routerContext = new RouterContext()
 3const rootRoute = routerContext.createRootRoute({
 4  component: () => { ... }
 5})
 6
 7const articleRoute = new Route({
 8  getParentRoute: () => rootRoute,
 9  path: 'article',
10  beforeLoad: () => {
11    return {
12      articleQueryOptions: { queryKey: ['article'], queryFn: fetchArticle },
13      commentsQueryOptions: { queryKey: ['comments'], queryFn: fetchComments },
14    }
15  },
16  loader: async ({
17    context: { queryClient },
18    routeContext: { articleQueryOptions, commentsQueryOptions },
19  }) => {
20    // Fetch comments asap, but don't block
21    queryClient.prefetchQuery(commentsQueryOptions)
22
23    // Don't render the route at all until article has been fetched
24    await queryClient.prefetchQuery(articleQueryOptions)
25  },
26  component: ({ useRouteContext }) => {
27    const { articleQueryOptions, commentsQueryOptions } = useRouteContext()
28    const articleQuery = useQuery(articleQueryOptions)
29    const commentsQuery = useQuery(commentsQueryOptions)
30
31    return (
32      ...
33    )
34  },
35  errorComponent: () => 'Oh crap!',
36})

Integration with other routers is also possible, see the React Router example for another demonstration.

Manually Priming a Query #

If you already have the data for your query synchronously available, you don't need to prefetch it. You can just use the Query Client's setQueryData method to directly add or update a query's cached result by key.

1queryClient.setQueryData(['todos'], todos)

Further reading #

For a deep-dive on how to get data into your Query Cache before you fetch, have a look at #17: Seeding the Query Cache from the Community Resources.

Integrating with Server Side routers and frameworks is very similar to what we just saw, with the addition that the data has to passed from the server to the client to be hydrated into the cache there. To learn how, continue on to the Server Rendering & Hydration guide.