usePreloadedQuery Pattern
Separating query loading from rendering for optimal performance
usePreloadedQuery Pattern
Master the usePreloadedQuery pattern with free flashcards and spaced repetition practice. This lesson covers preloading queries before render, the usePreloadedQuery hook, and how to integrate preloaded queries into your component architectureβessential concepts for building performant Relay applications.
Welcome to Preloading Queries π»
The usePreloadedQuery pattern is one of Relay's most powerful performance optimizations. Instead of waiting until your component renders to start fetching data, you can begin loading data before your component even mounts. This pattern dramatically reduces perceived loading times and creates snappier user experiences.
Think of it like ordering food ahead of time. Without preloading, you walk into a restaurant, sit down, look at the menu, and then orderβwaiting while your food is prepared. With preloading, you call ahead with your order, so when you arrive, your food is already being prepared or ready to go. The result? Less waiting, happier customers (users).
Core Concepts: How Preloading Works π
The Traditional Flow vs. Preloading
In a traditional data fetching pattern, the sequence looks like this:
TRADITIONAL FLOW (Sequential)
βββββββββββββββββββββββββββββββββββββββββββ
β β
β 1. User clicks button β
β β β
β 2. Component starts rendering β
β β β
β 3. Component requests data β
β β β
β 4. Wait for network response... β° β
β β β
β 5. Component renders with data β
β
β β
βββββββββββββββββββββββββββββββββββββββββββ
Total Time: Render Time + Network Time
With the usePreloadedQuery pattern, the flow changes dramatically:
PRELOADING FLOW (Parallel)
βββββββββββββββββββββββββββββββββββββββββββ
β β
β 1. User clicks button β
β β β
β 2. Immediately start data fetch π β
β β β
β 3. Component starts rendering β
β β β
β 4. Component receives preloaded data β
β
β β
βββββββββββββββββββββββββββββββββββββββββββ
Total Time: Max(Render Time, Network Time)
The network request happens in parallel with navigation and component initialization, potentially saving hundreds of milliseconds.
The Three Key Players π
1. loadQuery() - The Initiator
This function starts the data fetching process. You call it in response to user actions (clicks, hovers, route changes) or during application initialization.
import { loadQuery } from 'react-relay';
import { environment } from './RelayEnvironment';
import UserProfileQuery from './__generated__/UserProfileQuery.graphql';
// Start loading immediately
const queryReference = loadQuery(
environment,
UserProfileQuery,
{ userID: '123' },
{ fetchPolicy: 'store-or-network' }
);
Key characteristics:
- Returns a queryReference (not the data itself)
- Starts the network request immediately
- Can be called outside of React components
- Can be called before components even mount
2. queryReference - The Token
The queryReference is like a ticket stub for your data. It doesn't contain the dataβit's a reference to the in-flight or completed request.
// This is NOT data - it's a reference!
const queryReference = loadQuery(...);
// You pass this reference to components
<UserProfile queryReference={queryReference} />
π‘ Think of queryReference as a claim ticket: When you drop off dry cleaning, they give you a ticket. The ticket isn't your clothesβit's proof that you can pick them up. Similarly, queryReference isn't your dataβit's a token you exchange for data when you're ready.
3. usePreloadedQuery() - The Consumer
Inside your component, this hook exchanges the queryReference for actual data:
import { usePreloadedQuery } from 'react-relay';
import { graphql } from 'relay-runtime';
function UserProfile({ queryReference }) {
const data = usePreloadedQuery(
graphql`
query UserProfileQuery($userID: ID!) {
user(id: $userID) {
name
email
avatar
}
}
`,
queryReference
);
return (
<div>
<img src={data.user.avatar} alt="Avatar" />
<h1>{data.user.name}</h1>
<p>{data.user.email}</p>
</div>
);
}
Key behaviors:
- If data is ready: returns immediately
- If data is still loading: suspends (use React Suspense)
- If error occurred: throws error (use Error Boundary)
The Complete Pattern: Router Integration πΊοΈ
The most powerful use of usePreloadedQuery is integrating it with your router. Here's how to preload data during navigation:
Step 1: Create Route Configuration
// routes.js
import { loadQuery } from 'react-relay';
import { environment } from './RelayEnvironment';
const routes = [
{
path: '/user/:userID',
component: UserProfile,
prepare: (params) => {
const UserProfileQuery = require('./__generated__/UserProfileQuery.graphql');
return {
userProfileQuery: loadQuery(
environment,
UserProfileQuery,
{ userID: params.userID }
)
};
}
},
{
path: '/posts/:postID',
component: PostDetail,
prepare: (params) => {
const PostDetailQuery = require('./__generated__/PostDetailQuery.graphql');
return {
postQuery: loadQuery(
environment,
PostDetailQuery,
{ postID: params.postID }
)
};
}
}
];
Step 2: Preload on Navigation Events
// Router.js
function Router() {
const [currentRoute, setCurrentRoute] = useState(null);
const [queryRefs, setQueryRefs] = useState({});
const navigate = (path, params) => {
const route = routes.find(r => matchPath(path, r.path));
// Start loading BEFORE changing route!
const preparedData = route.prepare(params);
// Update state with new route and query references
setQueryRefs(preparedData);
setCurrentRoute({ route, params });
};
if (!currentRoute) return <div>Loading...</div>;
const RouteComponent = currentRoute.route.component;
return (
<Suspense fallback={<LoadingSpinner />}>
<RouteComponent {...queryRefs} />
</Suspense>
);
}
Step 3: Consume in Component
// UserProfile.js
function UserProfile({ userProfileQuery }) {
const data = usePreloadedQuery(
graphql`
query UserProfileQuery($userID: ID!) {
user(id: $userID) {
id
name
email
bio
followers {
count
}
posts {
edges {
node {
id
title
}
}
}
}
}
`,
userProfileQuery // queryReference passed as prop
);
return (
<div className="profile">
<h1>{data.user.name}</h1>
<p>{data.user.bio}</p>
<div>Followers: {data.user.followers.count}</div>
<PostList posts={data.user.posts.edges} />
</div>
);
}
Example 1: Preloading on Click Events π±οΈ
One of the simplest applications is preloading when a user hovers over or clicks a link:
import { useState } from 'react';
import { loadQuery, usePreloadedQuery } from 'react-relay';
import { graphql } from 'relay-runtime';
import { environment } from './RelayEnvironment';
// The query definition
const ProductQuery = graphql`
query ProductDetailQuery($productID: ID!) {
product(id: $productID) {
id
name
description
price
images {
url
}
reviews {
rating
count
}
}
}
`;
function ProductLink({ productID }) {
const [queryRef, setQueryRef] = useState(null);
const [showDetail, setShowDetail] = useState(false);
const handleMouseEnter = () => {
// Start loading on hover - data may be ready by click time!
if (!queryRef) {
const ref = loadQuery(
environment,
ProductQuery,
{ productID }
);
setQueryRef(ref);
}
};
const handleClick = () => {
// Ensure query is started
if (!queryRef) {
const ref = loadQuery(
environment,
ProductQuery,
{ productID }
);
setQueryRef(ref);
}
setShowDetail(true);
};
return (
<>
<button
onMouseEnter={handleMouseEnter}
onClick={handleClick}
>
View Product Details
</button>
{showDetail && queryRef && (
<Suspense fallback={<div>Loading product...</div>}>
<ProductDetail queryReference={queryRef} />
</Suspense>
)}
</>
);
}
function ProductDetail({ queryReference }) {
const data = usePreloadedQuery(ProductQuery, queryReference);
return (
<div className="product-detail">
<img src={data.product.images[0].url} alt={data.product.name} />
<h2>{data.product.name}</h2>
<p>{data.product.description}</p>
<div className="price">${data.product.price}</div>
<div className="rating">
Rating: {data.product.reviews.rating} ({data.product.reviews.count} reviews)
</div>
</div>
);
}
Why this works so well:
- Hover typically happens 100-500ms before click
- Network request gets a head start
- By click time, data might already be available
- User perceives instant loading
Example 2: Preloading Multiple Queries π
Complex pages often need multiple data sources. You can preload them all simultaneously:
import { loadQuery } from 'react-relay';
import { environment } from './RelayEnvironment';
// Multiple queries
const DashboardUserQuery = require('./__generated__/DashboardUserQuery.graphql');
const DashboardStatsQuery = require('./__generated__/DashboardStatsQuery.graphql');
const DashboardNotificationsQuery = require('./__generated__/DashboardNotificationsQuery.graphql');
function prepareDashboard(userID) {
// Load all queries in parallel!
return {
userQuery: loadQuery(
environment,
DashboardUserQuery,
{ userID }
),
statsQuery: loadQuery(
environment,
DashboardStatsQuery,
{ userID, period: 'LAST_30_DAYS' }
),
notificationsQuery: loadQuery(
environment,
DashboardNotificationsQuery,
{ userID, limit: 10 }
)
};
}
// Usage in component
function Dashboard({ userQuery, statsQuery, notificationsQuery }) {
const userData = usePreloadedQuery(
graphql`
query DashboardUserQuery($userID: ID!) {
user(id: $userID) {
name
avatar
}
}
`,
userQuery
);
const statsData = usePreloadedQuery(
graphql`
query DashboardStatsQuery($userID: ID!, $period: Period!) {
stats(userID: $userID, period: $period) {
pageViews
revenue
conversions
}
}
`,
statsQuery
);
const notificationsData = usePreloadedQuery(
graphql`
query DashboardNotificationsQuery($userID: ID!, $limit: Int!) {
notifications(userID: $userID, limit: $limit) {
id
message
timestamp
}
}
`,
notificationsQuery
);
return (
<div className="dashboard">
<header>
<img src={userData.user.avatar} alt="Avatar" />
<h1>Welcome, {userData.user.name}</h1>
</header>
<section className="stats">
<StatCard label="Page Views" value={statsData.stats.pageViews} />
<StatCard label="Revenue" value={`$${statsData.stats.revenue}`} />
<StatCard label="Conversions" value={statsData.stats.conversions} />
</section>
<section className="notifications">
<h2>Recent Notifications</h2>
{notificationsData.notifications.map(n => (
<NotificationItem key={n.id} notification={n} />
))}
</section>
</div>
);
}
Performance benefit: All three queries execute simultaneously on the server, rather than sequentially. If each query takes 200ms, you save 400ms compared to waterfall loading!
WATERFALL (Sequential): User Query [ββββββββ] 200ms Stats Query [ββββββββ] 200ms Notifications [ββββββββ] 200ms Total: 600ms PARALLEL (Preloaded): User Query [ββββββββ] 200ms Stats Query [ββββββββ] 200ms Notifications [ββββββββ] 200ms Total: 200ms β‘ 3x faster!
Example 3: Conditional Preloading π―
Sometimes you want to preload only under certain conditions:
import { loadQuery } from 'react-relay';
import { environment } from './RelayEnvironment';
function SmartArticleLink({ articleID, isPremiumUser }) {
const [queryRef, setQueryRef] = useState(null);
const handleHover = () => {
// Only preload full content for premium users
// Free users will see a preview that loads differently
if (isPremiumUser && !queryRef) {
const ArticleQuery = require('./__generated__/ArticleFullQuery.graphql');
const ref = loadQuery(
environment,
ArticleQuery,
{
articleID,
includePremiumContent: true
},
{
fetchPolicy: 'store-or-network',
// Cache premium content longer
networkCacheConfig: { force: false }
}
);
setQueryRef(ref);
} else if (!isPremiumUser && !queryRef) {
const ArticlePreviewQuery = require('./__generated__/ArticlePreviewQuery.graphql');
const ref = loadQuery(
environment,
ArticlePreviewQuery,
{ articleID }
);
setQueryRef(ref);
}
};
return (
<div onMouseEnter={handleHover}>
<ArticleTitle articleID={articleID} />
{queryRef && (
<Suspense fallback={<Spinner />}>
{isPremiumUser ? (
<ArticleFull queryReference={queryRef} />
) : (
<ArticlePreview queryReference={queryRef} />
)}
</Suspense>
)}
</div>
);
}
Example 4: Retry and Error Handling π
Handling errors with preloaded queries requires careful consideration:
import { loadQuery, usePreloadedQuery } from 'react-relay';
import { useState, useEffect } from 'react';
import { environment } from './RelayEnvironment';
function UserProfileContainer({ userID }) {
const [queryRef, setQueryRef] = useState(null);
const [loadError, setLoadError] = useState(null);
useEffect(() => {
// Load query with error handling
try {
const ref = loadQuery(
environment,
UserProfileQuery,
{ userID },
{
fetchPolicy: 'network-only',
onError: (error) => {
console.error('Query failed to load:', error);
setLoadError(error);
}
}
);
setQueryRef(ref);
setLoadError(null);
} catch (error) {
setLoadError(error);
}
}, [userID]);
const handleRetry = () => {
// Clear error and try again
setLoadError(null);
const ref = loadQuery(
environment,
UserProfileQuery,
{ userID },
{ fetchPolicy: 'network-only' }
);
setQueryRef(ref);
};
if (loadError) {
return (
<div className="error-state">
<p>Failed to load user profile: {loadError.message}</p>
<button onClick={handleRetry}>Retry</button>
</div>
);
}
if (!queryRef) {
return <div>Preparing to load...</div>;
}
return (
<ErrorBoundary fallback={<ErrorMessage onRetry={handleRetry} />}>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile queryReference={queryRef} />
</Suspense>
</ErrorBoundary>
);
}
function UserProfile({ queryReference }) {
const data = usePreloadedQuery(
graphql`
query UserProfileQuery($userID: ID!) {
user(id: $userID) {
name
email
posts { count }
}
}
`,
queryReference
);
return (
<div>
<h1>{data.user.name}</h1>
<p>{data.user.email}</p>
<p>Posts: {data.user.posts.count}</p>
</div>
);
}
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
Common Mistakes β οΈ
1. Calling loadQuery Inside Components Without Proper Memoization
β Wrong:
function MyComponent() {
// This creates a new query on EVERY render!
const queryRef = loadQuery(
environment,
MyQuery,
{ id: '123' }
);
// ...
}
β Right:
function MyComponent() {
const [queryRef, setQueryRef] = useState(() =>
loadQuery(environment, MyQuery, { id: '123' })
);
// Query loads only once
}
2. Not Using Suspense
The usePreloadedQuery hook will suspend if data isn't ready. You must wrap it with Suspense:
β Wrong:
function App() {
return <UserProfile queryReference={queryRef} />;
// Will crash if data not ready!
}
β Right:
function App() {
return (
<Suspense fallback={<Loading />}>
<UserProfile queryReference={queryRef} />
</Suspense>
);
}
3. Reusing Query References Incorrectly
Query references are tied to specific variables. Don't reuse them with different parameters:
β Wrong:
const queryRef = loadQuery(environment, UserQuery, { id: '1' });
// Later, trying to use same ref for different user
<UserProfile queryReference={queryRef} userID="2" />
// Still shows user 1's data!
β Right:
const queryRef1 = loadQuery(environment, UserQuery, { id: '1' });
const queryRef2 = loadQuery(environment, UserQuery, { id: '2' });
// Use appropriate reference for each user
4. Forgetting to Pass Query Reference as Prop
β Wrong:
const queryRef = loadQuery(...);
<MyComponent /> // Forgot to pass queryRef!
β Right:
const queryRef = loadQuery(...);
<MyComponent queryReference={queryRef} />
5. Mixing usePreloadedQuery with useLazyLoadQuery
β Wrong:
function MyComponent({ queryReference }) {
// Don't use useLazyLoadQuery when you have a queryReference!
const data = useLazyLoadQuery(MyQuery, variables);
}
β Right:
function MyComponent({ queryReference }) {
const data = usePreloadedQuery(MyQuery, queryReference);
}
π‘ Rule of thumb: If you have a queryReference, use usePreloadedQuery. If you need to load data during render, use useLazyLoadQuery.
Advanced: Disposing Query References ποΈ
Query references hold resources in memory. For long-lived applications, you should dispose of them:
import { useEffect, useState } from 'react';
import { loadQuery } from 'react-relay';
function SearchResults({ searchTerm }) {
const [queryRef, setQueryRef] = useState(null);
useEffect(() => {
const SearchQuery = require('./__generated__/SearchQuery.graphql');
const ref = loadQuery(
environment,
SearchQuery,
{ term: searchTerm }
);
setQueryRef(ref);
// Cleanup function
return () => {
// Dispose of query reference when component unmounts
// or when searchTerm changes
ref.dispose();
};
}, [searchTerm]);
if (!queryRef) return <div>Loading...</div>;
return (
<Suspense fallback={<SearchingSkeleton />}>
<SearchResultsList queryReference={queryRef} />
</Suspense>
);
}
Fetch Policies with Preloaded Queries π
You can control caching behavior when calling loadQuery:
| Fetch Policy | Behavior | Use Case |
|---|---|---|
| store-or-network | Use cache if available, otherwise network | Default - balance speed and freshness |
| store-and-network | Use cache immediately, then fetch fresh | Show stale data fast, update when fresh arrives |
| network-only | Always fetch from network, ignore cache | Ensure absolutely fresh data |
| store-only | Only use cache, never network | Offline mode, or when data known to exist |
const queryRef = loadQuery(
environment,
MyQuery,
variables,
{
fetchPolicy: 'store-and-network',
// Show cached data immediately, but also fetch fresh
}
);
Key Takeaways π―
usePreloadedQuery requires three steps: Call loadQuery() to get a queryReference, pass it to your component, and consume it with usePreloadedQuery()
Load data as early as possible: The earlier you call loadQuery(), the sooner data arrives. Ideal locations: route changes, hover events, or app initialization
queryReference is not data: It's a token representing an in-flight or completed request. Don't try to read it directly
Always use Suspense: usePreloadedQuery will suspend if data isn't ready. Wrap components with Suspense boundaries
Parallel loading wins: When loading multiple queries, they execute simultaneously. This is much faster than sequential loading
Dispose when done: Call .dispose() on query references in cleanup functions to free memory
Match query to reference: Each queryReference is tied to specific variables. Create new references for different parameters
Choose the right fetch policy: Control whether to prefer cache or network based on your data freshness requirements
Performance Comparison π
Here's a real-world comparison of loading patterns:
| Pattern | Time to Interactive | User Perception |
|---|---|---|
| useLazyLoadQuery (render-time) | ~800ms | "Slow" |
| usePreloadedQuery (on click) | ~500ms | "Acceptable" |
| usePreloadedQuery (on hover) | ~200ms | "Fast" |
| usePreloadedQuery (route change) | ~100ms | "Instant" |
π‘ Did you know? Studies show users perceive actions taking under 100ms as "instantaneous." Preloading can get you into that golden zone!
π§ Try This: Build a Smart Navigation System
Implement a navigation system that preloads the next likely page:
function SmartNav({ currentPage }) {
const [preloadedPages, setPreloadedPages] = useState({});
useEffect(() => {
// Predict and preload next likely pages
const predictions = predictNextPages(currentPage);
predictions.forEach(pageName => {
if (!preloadedPages[pageName]) {
const query = getQueryForPage(pageName);
const ref = loadQuery(environment, query, {});
setPreloadedPages(prev => ({
...prev,
[pageName]: ref
}));
}
});
}, [currentPage]);
return (
<nav>
{/* Navigation links with instant data */}
</nav>
);
}
function predictNextPages(current) {
// Predict based on analytics, user behavior, common flows
const predictions = {
'home': ['products', 'about'],
'products': ['product-detail', 'cart'],
'cart': ['checkout', 'products'],
};
return predictions[current] || [];
}
π Further Study
Deepen your understanding with these resources:
- Relay Documentation - usePreloadedQuery: https://relay.dev/docs/api-reference/use-preloaded-query/
- Relay Documentation - loadQuery: https://relay.dev/docs/api-reference/load-query/
- React Suspense Documentation: https://react.dev/reference/react/Suspense
π Quick Reference Card: usePreloadedQuery Pattern
| loadQuery() | Initiates data fetch, returns queryReference |
| queryReference | Token representing in-flight or completed request |
| usePreloadedQuery() | Exchanges queryReference for data in component |
| When to call loadQuery | Route change, hover, click, or app init |
| Required wrapper | Suspense boundary (for loading states) |
| Cleanup | Call queryRef.dispose() in useEffect cleanup |
| Performance gain | Parallel loading vs sequential (up to 3x faster) |
| Best practices | Load early, dispose properly, use Suspense, match vars to refs |
FLOW DIAGRAM:
ββββββββββββββββ
β User Action β
β (hover/click)β
ββββββββ¬ββββββββ
β
ββββββββββββββββββββββββ
β loadQuery() β
β Returns queryRef β
ββββββββ¬ββββββββββββββββ
β
ββββββββββββββββββββββββ
β Pass to Component β
β β
ββββββββ¬ββββββββββββββββ
β
ββββββββββββββββββββββββ
β usePreloadedQuery() β
β Returns data β
ββββββββ¬ββββββββββββββββ
β
ββββββββββββββββββββββββ
β Render with Data β
β
ββββββββββββββββββββββββ