You are viewing a preview of this lesson. Sign in to start learning
Back to Mastering Relay

useLazyLoadQuery for Routes

When to use lazy loading at route boundaries and entry points

useLazyLoadQuery for Routes

Master Relay data fetching with free flashcards and spaced repetition practice to solidify your understanding. This lesson covers useLazyLoadQuery fundamentals, route-based data loading patterns, and best practices for implementing efficient GraphQL queries in React applicationsβ€”essential skills for building performant modern web applications with Relay.

Welcome

Welcome to one of the most practical patterns in Relay development! πŸ’» If you've been working with Relay, you've likely encountered the challenge of fetching data when users navigate between routes. Unlike preloading strategies that require careful orchestration, useLazyLoadQuery offers a straightforward approach: fetch data exactly when a component renders.

This hook is particularly valuable for route components where you need data immediately upon navigation. While it's called "lazy," don't let the name fool youβ€”it's about lazy initialization of the query, not lazy loading of components. Think of it as your "fetch-on-render" tool that bridges the gap between traditional data fetching patterns and Relay's more advanced preloading capabilities.

In this lesson, we'll explore how to implement useLazyLoadQuery effectively, understand when it's the right choice, and learn the patterns that make route-based data fetching seamless and maintainable. πŸš€

Core Concepts

What is useLazyLoadQuery?

useLazyLoadQuery is a Relay hook that initiates a GraphQL query when a component renders. It's designed specifically for scenarios where you want to start fetching data at render time rather than in response to user interactions or during route transitions.

πŸ“‹ useLazyLoadQuery Signature

ParameterTypeDescription
queryGraphQLTaggedNodeThe compiled GraphQL query
variablesObjectQuery variables (must be stable reference)
optionsObject (optional)fetchPolicy, networkCacheConfig, etc.

The hook returns the query data and automatically suspends the component while fetching. This means you must wrap components using useLazyLoadQuery in a Suspense boundary.

The Fetch-on-Render Pattern

When you use useLazyLoadQuery in a route component, here's what happens:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         FETCH-ON-RENDER TIMELINE                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

    User navigates β†’ Component renders β†’ Query starts
         β”‚                 β”‚                  β”‚
         β–Ό                 β–Ό                  β–Ό
      πŸ–±οΈ Click         βš›οΈ React           πŸ“‘ Network
         β”‚              renders             request
         β”‚              component              β”‚
         β”‚                 β”‚                   β”‚
         β”‚                 β”œβ”€β†’ Suspense        β”‚
         β”‚                 β”‚   shows           β”‚
         β”‚                 β”‚   fallback        β”‚
         β”‚                 β”‚      β”‚            β”‚
         β”‚                 β”‚      β–Ό            β–Ό
         β”‚                 β”‚   ⏳ Loading   πŸ”„ Fetch
         β”‚                 β”‚                   β”‚
         β”‚                 β”‚                   β–Ό
         β”‚                 β”‚                βœ… Data
         β”‚                 β”‚                arrives
         β”‚                 β”‚                   β”‚
         └─────────────────┴────────────────────
                                               β”‚
                                               β–Ό
                                    Component renders
                                    with data πŸŽ‰

This pattern is simpler than preloading but creates a "waterfall" where the network request doesn't start until React has rendered your component. For many applications, especially those with fast networks or cached data, this trade-off is perfectly acceptable.

Setting Up useLazyLoadQuery in Routes

Here's the essential structure for a route component using useLazyLoadQuery:

import { useLazyLoadQuery } from 'react-relay';
import { graphql } from 'relay-runtime';

function UserProfileRoute({ userId }) {
  const data = useLazyLoadQuery(
    graphql`
      query UserProfileRouteQuery($userId: ID!) {
        user(id: $userId) {
          id
          name
          email
          ...UserProfile_user
        }
      }
    `,
    { userId },
    { fetchPolicy: 'store-or-network' }
  );

  return <UserProfile user={data.user} />;
}

Key elements:

  1. Query naming convention: Use ComponentName + Query suffix (e.g., UserProfileRouteQuery)
  2. Variables: Pass route parameters as query variables
  3. Fragment spreading: Spread fragments for child components to fetch their data
  4. Fetch policy: Control cache behavior with options

πŸ’‘ Tip: Always use the store-or-network fetch policy for route components. This ensures users see cached data immediately while fresh data loads in the background.

Variables and Stability

One critical aspect of useLazyLoadQuery is variable stability. Relay uses variables to determine if it needs to refetch data. If variables change, the query re-executes.

// ❌ WRONG - creates new object every render
function BadRoute() {
  const data = useLazyLoadQuery(
    query,
    { userId: "123" }, // New object reference each time!
    options
  );
}

// βœ… RIGHT - stable reference from props or state
function GoodRoute({ userId }) {
  const data = useLazyLoadQuery(
    query,
    { userId }, // Props are stable between renders
    options
  );
}

// βœ… ALSO RIGHT - useMemo for computed variables
function ComputedRoute({ rawId }) {
  const variables = useMemo(
    () => ({ userId: rawId.toUpperCase() }),
    [rawId]
  );
  
  const data = useLazyLoadQuery(query, variables, options);
}

⚠️ Warning: Creating new variable objects on every render causes infinite refetch loops. Always ensure variables have stable references.

Detailed Implementation Examples

Example 1: Basic Route with useLazyLoadQuery

Let's build a product detail page that loads when users navigate to /product/:id:

import { useLazyLoadQuery } from 'react-relay';
import { graphql } from 'relay-runtime';
import { Suspense } from 'react';

function ProductDetailRoute({ productId }) {
  const data = useLazyLoadQuery(
    graphql`
      query ProductDetailRouteQuery($productId: ID!) {
        product(id: $productId) {
          id
          name
          price
          description
          images {
            url
            alt
          }
          ...ProductReviews_product
          ...ProductRecommendations_product
        }
      }
    `,
    { productId },
    { fetchPolicy: 'store-or-network' }
  );

  if (!data.product) {
    return <NotFound />;
  }

  return (
    <div className="product-detail">
      <h1>{data.product.name}</h1>
      <p className="price">${data.product.price}</p>
      <p>{data.product.description}</p>
      
      <ProductReviews product={data.product} />
      <ProductRecommendations product={data.product} />
    </div>
  );
}

// In your router setup
function App() {
  return (
    <Router>
      <Route path="/product/:id" element={
        <Suspense fallback={<ProductSkeleton />}>
          <ProductDetailRoute productId={params.id} />
        </Suspense>
      } />
    </Router>
  );
}

What's happening here:

  • The query executes immediately when ProductDetailRoute renders
  • While fetching, Suspense shows <ProductSkeleton />
  • Child components (ProductReviews, ProductRecommendations) receive their data via fragments
  • If the product doesn't exist, we handle the null case gracefully

Example 2: Search Results Route with Dynamic Variables

Search pages are perfect for useLazyLoadQuery because query parameters change frequently:

import { useLazyLoadQuery } from 'react-relay';
import { graphql } from 'relay-runtime';
import { useSearchParams } from 'react-router-dom';
import { useMemo } from 'react';

function SearchResultsRoute() {
  const [searchParams] = useSearchParams();
  
  // Compute stable variables from URL parameters
  const variables = useMemo(() => ({
    query: searchParams.get('q') || '',
    category: searchParams.get('category') || 'all',
    sortBy: searchParams.get('sort') || 'relevance',
    first: 20
  }), [
    searchParams.get('q'),
    searchParams.get('category'),
    searchParams.get('sort')
  ]);

  const data = useLazyLoadQuery(
    graphql`
      query SearchResultsRouteQuery(
        $query: String!
        $category: String!
        $sortBy: SortOrder!
        $first: Int!
      ) {
        search(
          query: $query
          category: $category
          sortBy: $sortBy
          first: $first
        ) {
          edges {
            node {
              id
              ...SearchResultCard_item
            }
          }
          totalCount
          pageInfo {
            hasNextPage
            endCursor
          }
        }
      }
    `,
    variables,
    { fetchPolicy: 'store-or-network' }
  );

  return (
    <div className="search-results">
      <h2>Found {data.search.totalCount} results</h2>
      <div className="results-grid">
        {data.search.edges.map(edge => (
          <SearchResultCard key={edge.node.id} item={edge.node} />
        ))}
      </div>
      {data.search.pageInfo.hasNextPage && (
        <LoadMoreButton cursor={data.search.pageInfo.endCursor} />
      )}
    </div>
  );
}

Key patterns demonstrated:

  • Using useMemo to create stable variables from URL search parameters
  • Proper dependency array that includes only the actual parameter values
  • Pagination support with cursor-based connections
  • Handling empty results gracefully

Example 3: Route with Fetch Policy Customization

Different routes need different caching strategies. Here's how to customize fetch policies:

import { useLazyLoadQuery } from 'react-relay';
import { graphql } from 'relay-runtime';

function DashboardRoute({ userId }) {
  // Dashboard needs fresh data - use network-only
  const data = useLazyLoadQuery(
    graphql`
      query DashboardRouteQuery($userId: ID!) {
        user(id: $userId) {
          id
          notifications(first: 10) {
            edges {
              node {
                id
                message
                createdAt
                read
              }
            }
          }
          tasks(status: PENDING) {
            totalCount
            edges {
              node {
                id
                title
                dueDate
              }
            }
          }
          recentActivity(first: 5) {
            edges {
              node {
                id
                type
                timestamp
              }
            }
          }
        }
      }
    `,
    { userId },
    { 
      fetchPolicy: 'network-only', // Always fetch fresh data
      networkCacheConfig: {
        poll: 30000 // Poll every 30 seconds
      }
    }
  );

  return (
    <div className="dashboard">
      <NotificationPanel notifications={data.user.notifications} />
      <TaskList tasks={data.user.tasks} />
      <ActivityFeed activity={data.user.recentActivity} />
    </div>
  );
}

function StaticContentRoute() {
  // Static content can use cache aggressively
  const data = useLazyLoadQuery(
    graphql`
      query StaticContentRouteQuery {
        termsOfService {
          content
          lastUpdated
        }
      }
    `,
    {},
    { fetchPolicy: 'store-or-network' } // Use cache when available
  );

  return (
    <div className="legal-page">
      <h1>Terms of Service</h1>
      <div dangerouslySetInnerHTML={{ __html: data.termsOfService.content }} />
      <p className="updated">Last updated: {data.termsOfService.lastUpdated}</p>
    </div>
  );
}

πŸ“‹ Fetch Policy Quick Reference

PolicyWhen to UseBehavior
store-or-networkMost routes (default)Return cached data if available, otherwise fetch
store-and-networkShow data, then updateReturn cache immediately, fetch in background
network-onlyAlways need fresh dataAlways fetch, ignore cache
store-onlyOffline modeOnly use cache, never fetch

Example 4: Route with Error Handling

Production routes need robust error handling:

import { useLazyLoadQuery } from 'react-relay';
import { graphql } from 'relay-runtime';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

function UserProfileRouteContent({ username }) {
  const data = useLazyLoadQuery(
    graphql`
      query UserProfileRouteContentQuery($username: String!) {
        userByUsername(username: $username) {
          id
          username
          displayName
          bio
          ...UserPosts_user
          ...UserFollowers_user
        }
      }
    `,
    { username },
    { fetchPolicy: 'store-or-network' }
  );

  if (!data.userByUsername) {
    return (
      <div className="not-found">
        <h1>User Not Found</h1>
        <p>The user @{username} doesn't exist.</p>
      </div>
    );
  }

  return (
    <div className="user-profile">
      <h1>{data.userByUsername.displayName}</h1>
      <p className="username">@{data.userByUsername.username}</p>
      <p className="bio">{data.userByUsername.bio}</p>
      
      <Suspense fallback={<Spinner />}>
        <UserPosts user={data.userByUsername} />
      </Suspense>
      
      <Suspense fallback={<Spinner />}>
        <UserFollowers user={data.userByUsername} />
      </Suspense>
    </div>
  );
}

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div className="error-state">
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
      <button onClick={resetErrorBoundary}>Try Again</button>
    </div>
  );
}

function UserProfileRoute({ username }) {
  return (
    <ErrorBoundary 
      FallbackComponent={ErrorFallback}
      onReset={() => {
        // Reset query cache if needed
        window.location.reload();
      }}
    >
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfileRouteContent username={username} />
      </Suspense>
    </ErrorBoundary>
  );
}

Error handling layers:

  1. ErrorBoundary: Catches network errors, GraphQL errors, and runtime errors
  2. Null checks: Handle missing data gracefully (user not found)
  3. Nested Suspense: Child components can suspend independently
  4. Retry mechanisms: Users can attempt to recover from errors

Common Mistakes

❌ Mistake 1: Forgetting Suspense Boundary

// WRONG - will crash!
function App() {
  return (
    <Router>
      <Route path="/user/:id" element={
        <UserRoute userId={params.id} /> // No Suspense!
      } />
    </Router>
  );
}

// RIGHT
function App() {
  return (
    <Router>
      <Route path="/user/:id" element={
        <Suspense fallback={<Loading />}>
          <UserRoute userId={params.id} />
        </Suspense>
      } />
    </Router>
  );
}

Why this matters: useLazyLoadQuery throws a Promise while fetching, which Suspense catches. Without Suspense, the Promise bubbles up and crashes your app.

❌ Mistake 2: Creating New Variable Objects

// WRONG - infinite loop!
function ProductRoute({ id }) {
  const data = useLazyLoadQuery(
    query,
    { productId: id, includeReviews: true }, // New object every render!
    options
  );
}

// RIGHT
function ProductRoute({ id }) {
  const variables = useMemo(
    () => ({ productId: id, includeReviews: true }),
    [id]
  );
  
  const data = useLazyLoadQuery(query, variables, options);
}

❌ Mistake 3: Using useLazyLoadQuery in Non-Route Components

// WRONG - causes waterfalls!
function UserProfile() {
  const userData = useLazyLoadQuery(userQuery, ...);
  
  return (
    <div>
      <UserAvatar user={userData.user} />
      {/* This only starts after userData loads! */}
      <UserPosts userId={userData.user.id} />
    </div>
  );
}

function UserPosts({ userId }) {
  const postsData = useLazyLoadQuery(postsQuery, { userId });
  // Waterfall! Posts query waits for user query
}

// RIGHT - use fragments instead
function UserProfile() {
  const userData = useLazyLoadQuery(
    graphql`
      query UserProfileQuery($userId: ID!) {
        user(id: $userId) {
          ...UserAvatar_user
          ...UserPosts_user # Fetch together!
        }
      }
    `,
    variables
  );
}

❌ Mistake 4: Not Handling Loading States at Route Level

// WRONG - generic loading for everything
<Suspense fallback={<div>Loading...</div>}>
  <Routes>
    <Route path="/dashboard" element={<Dashboard />} />
    <Route path="/profile" element={<Profile />} />
  </Routes>
</Suspense>

// RIGHT - specific loading per route
<Routes>
  <Route path="/dashboard" element={
    <Suspense fallback={<DashboardSkeleton />}>
      <Dashboard />
    </Suspense>
  } />
  <Route path="/profile" element={
    <Suspense fallback={<ProfileSkeleton />}>
      <Profile />
    </Suspense>
  } />
</Routes>

Why this matters: Route-specific loading states provide better UX. Users see skeleton screens that match the content structure rather than generic spinners.

❌ Mistake 5: Mixing Preload with useLazyLoadQuery

// CONFUSING - why preload if using lazy?
function UserRoute({ userId }) {
  // Preloading here is pointless
  const preloadedQuery = useQueryLoader(userQuery);
  
  useEffect(() => {
    preloadedQuery.loadQuery({ userId });
  }, [userId]);
  
  // Then using lazy load anyway?
  const data = useLazyLoadQuery(userQuery, { userId });
}

// PICK ONE:
// Option A: Just use lazy (simpler)
function UserRoute({ userId }) {
  const data = useLazyLoadQuery(userQuery, { userId });
}

// Option B: Use preload pattern (faster)
function UserRoute({ queryRef }) {
  const data = usePreloadedQuery(userQuery, queryRef);
}

πŸ’‘ Rule of thumb: Use useLazyLoadQuery for simplicity, or use the full preload pattern for performance. Don't mix them.

Key Takeaways

🎯 Core Principles

When to use useLazyLoadQuery:

  • βœ… Route components that need immediate data
  • βœ… Simple applications without complex preloading needs
  • βœ… Scenarios where fetch-on-render is acceptable
  • βœ… Prototyping and rapid development

When NOT to use useLazyLoadQuery:

  • ❌ Deeply nested components (use fragments instead)
  • ❌ Data needed before navigation completes (use preloading)
  • ❌ Components that re-render frequently
  • ❌ Performance-critical applications with slow networks

Essential patterns:

  1. Always wrap in Suspense boundary
  2. Use stable variable references (useMemo when needed)
  3. Implement route-specific loading states
  4. Add ErrorBoundary for production resilience
  5. Choose appropriate fetch policies for your data
  6. Spread fragments for child component data needs

Performance considerations:

Request Waterfall Comparison

PRELOADING:
    Click β†’ Start Fetch β†’ Render β†’ Show Data
    β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
       Fast!      Already loaded

LAZY LOADING:
    Click β†’ Render β†’ Start Fetch β†’ Show Data
    β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
      Fast       Slower (waterfall)

The waterfall in lazy loading means users wait longer, but the code is simpler. Choose based on your application's priorities.

Best practices checklist:

  • Every useLazyLoadQuery is wrapped in Suspense
  • Variables use stable references (props, state, or useMemo)
  • Route components have ErrorBoundary
  • Loading states match page structure (skeleton screens)
  • Fetch policy matches data freshness requirements
  • Child components use fragments, not separate queries
  • Query names follow convention: ComponentNameQuery

🧠 Memory device for fetch policies:

  • Store-Or-Network: SO common (most routes)
  • Store-And-Network: SAve time (show cached, then update)
  • Network-Only: NO cache (always fresh)

πŸ“š Further Study

  1. Relay Official Documentation - useLazyLoadQuery: https://relay.dev/docs/api-reference/use-lazy-load-query/ - Comprehensive API reference with all options and behaviors

  2. Relay Fetch Policies Guide: https://relay.dev/docs/guided-tour/reusing-cached-data/fetch-policies/ - Deep dive into when to use each fetch policy and cache behavior

  3. React Suspense for Data Fetching: https://react.dev/reference/react/Suspense - Understanding the Suspense mechanism that powers useLazyLoadQuery


πŸ“‹ Quick Reference Card: useLazyLoadQuery

Basic Syntax:

const data = useLazyLoadQuery(query, variables, options);

Required Setup:

Suspense boundaryWrap component to handle loading
ErrorBoundaryCatch network/GraphQL errors
Stable variablesUse props, state, or useMemo

Common Fetch Policies:

store-or-networkDefault for most routes
network-onlyReal-time/fresh data
store-and-networkShow cached, update later

Query Naming: ComponentName + Query β†’ UserProfileRouteQuery

Fragment Pattern:

query MyRouteQuery {
  user {
    ...ChildComponent_user
  }
}

Error Handling Flow: ErrorBoundary β†’ Suspense β†’ useLazyLoadQuery β†’ null checks