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
| Parameter | Type | Description |
|---|---|---|
| query | GraphQLTaggedNode | The compiled GraphQL query |
| variables | Object | Query variables (must be stable reference) |
| options | Object (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:
- Query naming convention: Use
ComponentName+Querysuffix (e.g.,UserProfileRouteQuery) - Variables: Pass route parameters as query variables
- Fragment spreading: Spread fragments for child components to fetch their data
- 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
useMemoto 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
| Policy | When to Use | Behavior |
|---|---|---|
| store-or-network | Most routes (default) | Return cached data if available, otherwise fetch |
| store-and-network | Show data, then update | Return cache immediately, fetch in background |
| network-only | Always need fresh data | Always fetch, ignore cache |
| store-only | Offline mode | Only 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:
- ErrorBoundary: Catches network errors, GraphQL errors, and runtime errors
- Null checks: Handle missing data gracefully (user not found)
- Nested Suspense: Child components can suspend independently
- 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:
- Always wrap in Suspense boundary
- Use stable variable references (useMemo when needed)
- Implement route-specific loading states
- Add ErrorBoundary for production resilience
- Choose appropriate fetch policies for your data
- 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
Relay Official Documentation - useLazyLoadQuery: https://relay.dev/docs/api-reference/use-lazy-load-query/ - Comprehensive API reference with all options and behaviors
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
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 boundary | Wrap component to handle loading |
| ErrorBoundary | Catch network/GraphQL errors |
| Stable variables | Use props, state, or useMemo |
Common Fetch Policies:
| store-or-network | Default for most routes |
| network-only | Real-time/fresh data |
| store-and-network | Show cached, update later |
Query Naming:
ComponentName + Query β UserProfileRouteQuery
Fragment Pattern:
query MyRouteQuery {
user {
...ChildComponent_user
}
}
Error Handling Flow: ErrorBoundary β Suspense β useLazyLoadQuery β null checks