skip to content

In Relay, how do useLazyLoadQuery and usePreloadedQuery with useQueryLoader differ in when an article page's query starts fetching?

level: middleimportance: should knowfreq 30%

answer

  1. fetch-on-render versus render-as-you-fetch
  2. who starts the request, and when
  3. a query reference held in state
  4. retain, dispose, garbage collection

basics

~20 s

useLazyLoadQuery starts fetching only when its component renders, so code loading and parent renders delay it and nested lazy queries waterfall. With useQueryLoader, an event handler or route transition calls loadQuery first, and usePreloadedQuery later reads that reference, suspending until it resolves.

solid answer

~40 s

`useLazyLoadQuery(query, variables)` is fetch-on-render: the request starts when the component renders, so anything that delays rendering, such as downloading the route's code or a parent's own query, delays the fetch, and a lazy query inside a lazily fetched subtree waits for its parent's round trip. `useQueryLoader(query)` returns `[queryReference, loadQuery, disposeQuery]`; you call `loadQuery(variables)` from the click on an article link or the router's transition, never during render, so the request is in flight while code loads and the tree renders. The component then calls `usePreloadedQuery(query, queryReference)`, which returns data, suspends while it is pending, or throws the error to an error boundary. `useQueryLoader` also retains the query and disposes it on unmount or on the next `loadQuery`. Both default to the `store-or-network` fetch policy. The Relay docs recommend the preloaded pattern.

code

tsx · 26 lines
tsx
import { Suspense } from 'react';
import { graphql, useQueryLoader, usePreloadedQuery, type PreloadedQuery } from 'react-relay';
import type { ArticleRouteQuery as Q } from './__generated__/ArticleRouteQuery.graphql';

const ArticleQuery = graphql`
  query ArticleRouteQuery($id: ID!) {
    article(id: $id) { title ...ArticleByline_article ...CommentThread_article }
  }
`;

export function ArticleRoute({ headlineId }: { headlineId: string }) {
  const [ref, loadQuery] = useQueryLoader<Q>(ArticleQuery);
  return (
    <>
      <button onClick={() => loadQuery({ id: headlineId })}>Read article</button>
      <Suspense fallback={<p>Loading article...</p>}>
        {ref && <ArticleBody queryRef={ref} />}
      </Suspense>
    </>
  );
}

function ArticleBody({ queryRef }: { queryRef: PreloadedQuery<Q> }) {
  const data = usePreloadedQuery(ArticleQuery, queryRef);
  return <h1>{data.article?.title}</h1>;
}

go deeper

for a junior

Recall that useLazyLoadQuery fetches when the component renders, while useQueryLoader's loadQuery starts the fetch earlier and usePreloadedQuery just reads the reference.

for a middle

Explain the render-as-you-fetch sequence, why nested lazy queries waterfall, where Suspense and error boundaries sit, and the store-or-network default for both hooks.

for a senior

Show you manage lifetimes: loadQuery only from events or routing, retention until dispose, garbage collection afterwards, and choosing store-and-network for data that must refresh on revisit.

for a principal

Argue for preloading as a routing-level convention across teams, so every route starts its composed query on intent instead of each screen discovering its data during render.

## Two ways to fetch a query Relay's React hooks separate **reading** a query's data from **starting** its fetch. Where the fetch starts decides how long a reader waits. Picture a news site: a reader taps a headline, the article route's code chunk downloads, the page renders, and the article query with its composed fragments (byline, body, comment thread, like button) goes to the server. ## useLazyLoadQuery: fetch-on-render `useLazyLoadQuery(ArticlePageQuery, { id })` sends the request **during the render** of the component that calls it. - The fetch cannot start before that component renders, so it waits for the route's code to download and for every ancestor to render. - If that component renders a lazily loaded child that also calls `useLazyLoadQuery`, the child's request starts only after the first response arrives and the child renders: two sequential round trips, a **waterfall**. - It suspends while the request is pending, so a `Suspense` boundary must sit above it, and network errors are thrown to an error boundary. - Re-rendering with the same variables does not refetch; new variables, a new `fetchKey`, or an unmount and remount re-evaluate the query under its fetch policy. The docs describe this hook as convenient but warn that it can cause nested or waterfalling round trips and that it starts later than it could. ## usePreloadedQuery with useQueryLoader: render-as-you-fetch 1. The parent calls `const [queryReference, loadQuery, disposeQuery] = useQueryLoader(ArticlePageQuery)`. 2. On the user's intent, in the headline's `onClick` or the router's transition, it calls `loadQuery({ id })`. The request starts **now**, in parallel with code loading and rendering. The docs say this callback should not be called during React's render phase. 3. `queryReference` becomes non-null and is passed to the article component inside a `Suspense` boundary. 4. The article component calls `usePreloadedQuery(ArticlePageQuery, queryReference)`: it returns data if ready, suspends if pending, and throws if the request failed. For route-level loading outside a component, the plain `loadQuery(environment, query, variables)` function returns a reference directly; then you must store it and call `.dispose()` yourself. ## Side by side | | `useLazyLoadQuery` | `useQueryLoader` + `usePreloadedQuery` | |---|---|---| | Fetch starts | when the calling component renders | when `loadQuery` is called, before render | | Waterfall risk | high for nested lazy queries | low: loads can start together | | Loading state | suspends | suspends | | Errors | thrown to an error boundary | thrown to an error boundary | | Retention | tied to the component | tied to the reference; disposed on unmount or next load | | Default `fetchPolicy` | `store-or-network` | `store-or-network` | ## Lifetime and garbage collection The Relay store garbage-collects records no live query retains. A reference from `useQueryLoader` is **retained** until it is disposed, so the article's data survives while the reference is in state. Calling `loadQuery` again (say, for the next article) disposes the previous reference, and `disposeQuery` sets it back to `null`. After disposal the data is liable to be collected, so a later visit may refetch under `store-or-network` if records were collected. ## Fetch policies apply to both - `store-or-network` (default): use the store if every field is present and not stale, otherwise fetch. - `store-and-network`: render cached data immediately and always refresh, useful for a comment count on a revisited article. - `network-only`: always fetch, ignoring cached data. - `store-only` (for `useLazyLoadQuery`): never fetch; read what the store has. ## Errors, retries and refreshing Both hooks report failures the same way: a network error is **thrown during render** and caught by the nearest error boundary, so place an error boundary next to the `Suspense` boundary around the article. To retry, reset the boundary and load again: with `useQueryLoader` that means calling `loadQuery` with the same variables, which creates a fresh reference and disposes the failed one. With `useLazyLoadQuery`, a changed `fetchKey` re-evaluates the query on the next render. Since Relay 18, a single failed field need not reach the boundary at all: `@catch` on that field puts `{ ok: true, value }` or `{ ok: false, errors }` into the data instead of throwing or nulling it. The same mechanism serves a pull-to-refresh on the article: `loadQuery(vars, { fetchPolicy: 'network-only' })` fetches again while the reference swap keeps the lifetime bookkeeping in one place. ## When the lazy hook is still reasonable - A prototype, or a leaf widget where one extra round trip is acceptable. - A screen with no preceding user intent to hook into. For the core article route of a news app, preloading pays for itself: the fetch overlaps the code download, and the whole page's composed query starts in one request.

  • Why should Relay's loadQuery from useQueryLoader not be called during render?
    Render can run several times or be thrown away, and each `loadQuery` call starts a fetch and disposes the previous reference. Calling it in the component body would issue requests as a side effect of rendering. Call it from an event handler, an effect or the router's transition, where it runs once per intent.
  • How do you make a revisited Relay article render its cached data at once but still refresh the comment count?
    Pass `fetchPolicy: 'store-and-network'` to `loadQuery` (or `useLazyLoadQuery`). Relay renders from the store without suspending when the data is present and always sends the request, and components re-render when the fresh response is written.
  • In Relay 21, how can the article page survive one failed field, such as the author's avatar, without the whole route falling to an error boundary?
    Put `@catch` on that field or an ancestor in the selection. With the default `to: RESULT`, the value reads as `{ ok: true, value }` or `{ ok: false, errors }`, so the byline can render a placeholder; `to: NULL` just nulls it. `@throwOnFieldError` does the opposite, throwing field errors on read. Both apply only to their own document, not to spread fragments.
  • What happens to an article's data in Relay after disposeQuery is called?
    The query is no longer retained, so its records become eligible for garbage collection once nothing else retains them. The data may still be in the store for a while, but a later load can find it missing and fetch again under the default `store-or-network` policy.

saying these in an interview costs you the question

  • usePreloadedQuery sends the request when its component renders.
  • Calling loadQuery in the component body is fine because Relay deduplicates it.
  • useLazyLoadQuery returns a loading flag instead of suspending.
  • Relay's default fetch policy always goes to the network.
  • A query reference from the plain loadQuery function never needs disposing.