Suspense and Concurrent Rendering
Integrating Relay with React's concurrent features
Suspense and Concurrent Rendering in Relay
Master Relay's advanced rendering patterns with free flashcards and spaced repetition practice. This lesson covers Suspense boundaries, concurrent rendering modes, and the Transition APIβessential concepts for building performant React applications with GraphQL data fetching.
Welcome to Advanced Relay Rendering π»
Relay's integration with React's Suspense and Concurrent Rendering features represents a paradigm shift in how we build data-driven UIs. Instead of managing loading states manually with booleans and conditionals, Relay leverages React's built-in mechanisms to coordinate asynchronous operations declaratively. This approach eliminates loading state boilerplate, prevents layout shifts, and enables sophisticated UX patterns like smooth transitions between views.
In this lesson, you'll learn how Relay's architecture aligns perfectly with React 18's concurrent features, enabling you to build applications that feel responsive even during expensive data fetching and rendering operations.
Core Concepts: Understanding Suspense in Relay π
What is Suspense?
Suspense is React's mechanism for handling asynchronous operations declaratively. When a component needs data that isn't yet available, it "suspends" (throws a special promise), and React catches this suspension at the nearest Suspense boundary, rendering the fallback UI until the data arrives.
βββββββββββββββββββββββββββββββββββββββββββ β Component Tree β βββββββββββββββββββββββββββββββββββββββββββ€ β β β}> β β β β β βββ Component needs data β β β β β β (suspends/throws promise) β β β β β βββ π React catches β shows β β fallback until resolved β β β β β β β βββββββββββββββββββββββββββββββββββββββββββ
Key Relay Hooks that Integrate with Suspense:
useLazyLoadQuery: Fetches data and suspends until it arrivesusePreloadedQuery: Reads preloaded data, suspends if not readyuseFragment: Reads fragment data (doesn't suspend itself, relies on parent)usePaginationFragment: Handles paginated data with suspense support
π‘ Pro Tip: Suspense boundaries act as "loading zones" in your component tree. Place them strategically to control granularityβone boundary for the whole page creates a single loading state, while multiple boundaries enable progressive loading.
The Relay Suspense Architecture
Relay's store architecture is designed specifically for Suspense. When you call useLazyLoadQuery, Relay:
- Checks the store for cached data
- Suspends if data is missing (throws promise)
- Initiates network request in parallel
- Resolves suspension when data arrives and is written to store
- Re-renders component with data from store
RELAY SUSPENSE FLOW
Component Render
β
β
useLazyLoadQuery()
β
β
βββββββββββββββββββ
β Check Store β
ββββββββββ¬βββββββββ
β
ββββββ΄βββββ
β β
β
Hit β Miss
β β
β β
β π SUSPEND
β β
β β
β Fetch Network
β β
β β
β Write to Store
β β
β β
β Resolve Promise
β β
ββββββ¬βββββ
β
β
Return Data
β
β
Component Renders
Concurrent Rendering: Time Slicing and Interruptibility β‘
Concurrent Rendering (React 18+) allows React to work on multiple versions of the UI simultaneously and interrupt rendering work to handle higher-priority updates. This prevents the UI from freezing during expensive operations.
Key Concepts:
- Transitions: Mark updates as non-urgent using
useTransitionorstartTransition - Interruptible Rendering: React can pause rendering to handle user input
- Automatic Batching: React batches multiple state updates efficiently
- Time Slicing: Long renders are split into chunks
| Update Type | Priority | Example | User Experience |
|---|---|---|---|
| Urgent | High | Typing, clicking, pressing | Immediate feedback required |
| Transition | Low | Navigation, filtering, sorting | Can show stale content briefly |
Suspense Boundaries: Strategic Placement π―
The placement of Suspense boundaries dramatically affects user experience. Let's explore different strategies:
Strategy 1: Single Page-Level Boundary
import { Suspense } from 'react';
import { useLazyLoadQuery } from 'react-relay';
import graphql from 'babel-plugin-relay/macro';
function App() {
return (
<Suspense fallback={<PageSpinner />}>
<Dashboard />
</Suspense>
);
}
function Dashboard() {
const data = useLazyLoadQuery(
graphql`
query DashboardQuery {
viewer {
name
posts { title }
friends { name }
}
}
`,
{}
);
return (
<div>
<UserProfile user={data.viewer} />
<PostList posts={data.viewer.posts} />
<FriendsList friends={data.viewer.friends} />
</div>
);
}
Pros: Simple, single loading state Cons: All-or-nothingβuser waits for all data before seeing anything
Strategy 2: Multiple Nested Boundaries
function Dashboard() {
return (
<div>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<PostList />
</Suspense>
<Suspense fallback={<FriendsSkeleton />}>
<FriendsList />
</Suspense>
</div>
);
}
function UserProfile() {
const data = useLazyLoadQuery(
graphql`query ProfileQuery { viewer { name avatar } }`,
{}
);
return <Profile data={data.viewer} />;
}
Pros: Progressive loadingβparts of UI appear as data arrives Cons: More complex, potential for layout shifts
π‘ Best Practice: Use skeleton screens (placeholder UI matching final layout) in fallbacks to prevent layout shift and maintain visual stability.
Strategy 3: Deferred Content with @defer
Relay's @defer directive tells GraphQL to send critical data first, then stream deferred fields:
function Dashboard() {
const data = useLazyLoadQuery(
graphql`
query DashboardQuery {
viewer {
name
avatar
...PostList_posts @defer
...FriendsList_friends @defer
}
}
`,
{}
);
return (
<div>
<Profile user={data.viewer} />
<Suspense fallback={<PostsSkeleton />}>
<PostList user={data.viewer} />
</Suspense>
<Suspense fallback={<FriendsSkeleton />}>
<FriendsList user={data.viewer} />
</Suspense>
</div>
);
}
How it works: Initial query returns name and avatar immediately. Posts and friends data stream in afterward, triggering Suspense boundaries as they arrive.
DEFERRED LOADING TIMELINE
t=0ms Initial Request Sent
β
β
t=50ms ββββββββββββββββββββββββ
β name: "Alice" β
β avatar: "...url" β β Critical data arrives
ββββββββββββββββββββββββ
β
β
Profile renders immediately
Posts/Friends show skeletons
β
t=200ms ββββββββββββββββββββββββ
β posts: [...] β β Deferred chunk 1
ββββββββββββββββββββββββ
β
β
PostList renders
β
t=350ms ββββββββββββββββββββββββ
β friends: [...] β β Deferred chunk 2
ββββββββββββββββββββββββ
β
β
FriendsList renders
The Transition API: Smooth Navigation π
Transitions allow you to mark updates as non-urgent, keeping the UI responsive during navigation and data fetching.
Basic Transition with useTransition
import { useTransition, Suspense, useState } from 'react';
import { useLazyLoadQuery } from 'react-relay';
function SearchPage() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleSearch = (newQuery) => {
// Update input immediately (urgent)
setQuery(newQuery);
// Defer search results (non-urgent transition)
startTransition(() => {
setSearchTerm(newQuery);
});
};
return (
<div>
<input
value={query}
onChange={(e) => handleSearch(e.target.value)}
style={{ opacity: isPending ? 0.6 : 1 }}
/>
<Suspense fallback={<SearchSkeleton />}>
<SearchResults term={searchTerm} />
</Suspense>
</div>
);
}
What happens:
- User types β input updates immediately (urgent)
startTransitionmarks results update as low-priority- React keeps showing old results while preparing new ones
- New results appear when ready (smooth, no jarring spinner)
Navigation with Transitions
import { useTransition } from 'react';
import { useNavigate } from 'react-router-dom';
function Navigation() {
const [isPending, startTransition] = useTransition();
const navigate = useNavigate();
const handleNavigation = (path) => {
startTransition(() => {
navigate(path);
});
};
return (
<nav style={{ opacity: isPending ? 0.7 : 1 }}>
<button onClick={() => handleNavigation('/dashboard')}>
Dashboard
</button>
<button onClick={() => handleNavigation('/profile')}>
Profile
</button>
{isPending && <span>Loading...</span>}
</nav>
);
}
β οΈ Important: Without transitions, navigation would freeze the UI until the next page loads. With transitions, the current page remains interactive.
Transition Decision Tree
Should I use a transition?
β
ββββββββββββ΄βββββββββββ
β β
Is update Is immediate
urgent (input, feedback
button press)? critical?
β β
ββββ΄βββ ββββ΄βββ
β YES β β YES β
ββββ¬βββ ββββ¬βββ
β β
β β
β NO TRANSITION β NO TRANSITION
Use normal state Use normal state
β β
ββββ΄βββ ββββ΄βββ
β NO β β NO β
ββββ¬βββ ββββ¬βββ
β β
β β
Can user wait Does update
briefly to see trigger data
old content? fetching?
β β
ββββ΄βββ ββββ΄βββ
β YES β β YES β
ββββ¬βββ ββββ¬βββ
β β
ββββββββββββ¬βββββββββββ
β
β
USE TRANSITION
startTransition(() => {...})
Example 1: Real-World Dashboard with Progressive Loading π
Let's build a dashboard that loads data progressively, showing critical information first:
import { Suspense } from 'react';
import { useLazyLoadQuery, useFragment } from 'react-relay';
import graphql from 'babel-plugin-relay/macro';
// Main Dashboard Component
function Dashboard() {
const data = useLazyLoadQuery(
graphql`
query DashboardQuery {
viewer {
id
...DashboardHeader_user
...DashboardStats_user @defer(label: "stats")
...DashboardActivity_user @defer(label: "activity")
}
}
`,
{},
{ fetchPolicy: 'store-or-network' }
);
return (
<div className="dashboard">
{/* Header loads immediately - no Suspense needed */}
<DashboardHeader user={data.viewer} />
{/* Stats deferred - shows skeleton until ready */}
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats user={data.viewer} />
</Suspense>
{/* Activity deferred - independent loading */}
<Suspense fallback={<ActivitySkeleton />}>
<DashboardActivity user={data.viewer} />
</Suspense>
</div>
);
}
// Header - Critical Data (not deferred)
function DashboardHeader({ user }) {
const data = useFragment(
graphql`
fragment DashboardHeader_user on User {
name
avatar
role
}
`,
user
);
return (
<header>
<img src={data.avatar} alt={data.name} />
<h1>{data.name}</h1>
<span>{data.role}</span>
</header>
);
}
// Stats - Deferred (loads after header)
function DashboardStats({ user }) {
const data = useFragment(
graphql`
fragment DashboardStats_user on User {
totalPosts
totalFollowers
engagementRate
}
`,
user
);
return (
<div className="stats">
<Stat label="Posts" value={data.totalPosts} />
<Stat label="Followers" value={data.totalFollowers} />
<Stat label="Engagement" value={`${data.engagementRate}%`} />
</div>
);
}
// Activity Feed - Deferred (loads independently)
function DashboardActivity({ user }) {
const data = useFragment(
graphql`
fragment DashboardActivity_user on User {
recentActivity {
id
type
description
timestamp
}
}
`,
user
);
return (
<div className="activity">
{data.recentActivity.map(activity => (
<ActivityItem key={activity.id} activity={activity} />
))}
</div>
);
}
Why this works well:
- User sees their name/avatar in ~50ms (immediate feedback)
- Stats appear in ~200ms (acceptable delay, shows skeleton)
- Activity feed loads in ~400ms (not critical, loads last)
- Each section loads independentlyβno blocking
Example 2: Search with Transitions and Debouncing π
Combine transitions with debouncing for smooth search UX:
import { useState, useTransition, useDeferredValue } from 'react';
import { useLazyLoadQuery } from 'react-relay';
import graphql from 'babel-plugin-relay/macro';
function SearchPage() {
const [inputValue, setInputValue] = useState('');
const [isPending, startTransition] = useTransition();
// Deferred value lags behind input for smooth typing
const deferredQuery = useDeferredValue(inputValue);
const handleChange = (e) => {
// Update input immediately (urgent)
setInputValue(e.target.value);
};
return (
<div>
<input
type="search"
value={inputValue}
onChange={handleChange}
placeholder="Search users..."
style={{
opacity: isPending ? 0.7 : 1,
transition: 'opacity 0.2s'
}}
/>
{/* Show stale results while fetching new ones */}
<Suspense fallback={<SearchSkeleton />}>
<SearchResults query={deferredQuery} />
</Suspense>
{isPending && (
<div className="search-indicator">
Updating results...
</div>
)}
</div>
);
}
function SearchResults({ query }) {
const data = useLazyLoadQuery(
graphql`
query SearchResultsQuery($query: String!) {
searchUsers(query: $query) {
id
name
avatar
bio
}
}
`,
{ query },
{ fetchPolicy: 'store-or-network' }
);
if (data.searchUsers.length === 0) {
return <p>No results found for "{query}"</p>;
}
return (
<ul className="results">
{data.searchUsers.map(user => (
<li key={user.id}>
<img src={user.avatar} alt="" />
<div>
<strong>{user.name}</strong>
<p>{user.bio}</p>
</div>
</li>
))}
</ul>
);
}
Key techniques:
useDeferredValue: Creates a "lagging" version of state that updates non-urgently- Input stays responsive: Types with no lag
- Stale results visible: Old results remain on screen while fetching
- Visual feedback: Opacity change indicates loading state
| Time | Input Value | Deferred Value | UI State |
|---|---|---|---|
| 0ms | "r" | "" | Shows old results |
| 50ms | "re" | "" | Shows old results |
| 100ms | "rea" | "" | Shows old results |
| 150ms | "reac" | "reac" | Fetches "reac" results |
| 300ms | "reac" | "reac" | Shows new results |
Example 3: Tab Navigation with Preloading β‘
Preload tab content on hover for instant navigation:
import { Suspense, useState, useRef } from 'react';
import { loadQuery, usePreloadedQuery, useQueryLoader } from 'react-relay';
import graphql from 'babel-plugin-relay/macro';
import { Environment } from './RelayEnvironment';
const TabQuery = graphql`
query TabContentQuery($tab: String!) {
content(tab: $tab) {
title
description
items { id name }
}
}
`;
function TabNavigation() {
const [activeTab, setActiveTab] = useState('overview');
const [queryRef, loadQueryRef, disposeQuery] = useQueryLoader(TabQuery);
const preloadTimeoutRef = useRef(null);
const tabs = ['overview', 'analytics', 'settings'];
const handleMouseEnter = (tab) => {
// Preload after 100ms hover (avoid accidental hovers)
preloadTimeoutRef.current = setTimeout(() => {
loadQueryRef({ tab });
}, 100);
};
const handleMouseLeave = () => {
clearTimeout(preloadTimeoutRef.current);
};
const handleClick = (tab) => {
// Ensure data is loading/loaded
loadQueryRef({ tab });
setActiveTab(tab);
};
return (
<div>
<nav>
{tabs.map(tab => (
<button
key={tab}
onClick={() => handleClick(tab)}
onMouseEnter={() => handleMouseEnter(tab)}
onMouseLeave={handleMouseLeave}
className={activeTab === tab ? 'active' : ''}
>
{tab}
</button>
))}
</nav>
<Suspense fallback={<TabSkeleton />}>
{queryRef && <TabContent queryRef={queryRef} />}
</Suspense>
</div>
);
}
function TabContent({ queryRef }) {
const data = usePreloadedQuery(TabQuery, queryRef);
return (
<div className="tab-content">
<h2>{data.content.title}</h2>
<p>{data.content.description}</p>
<ul>
{data.content.items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
Performance optimization:
- Hover intent detection: 100ms delay prevents accidental preloads
- Preloaded queries: Data ready before click
- Instant tab switching: No waiting when data is cached
- Cleanup:
disposeQueryprevents memory leaks
PRELOADING TIMELINE
User hovers tab
β
β
Wait 100ms
β
β
Start preload βββ Fetch in background
β β
β β
User clicks Data arrives
β β
ββββββββββββ¬ββββββββββ
β
β
Instant render
(data already in store)
Example 4: Optimistic Updates with Concurrent Rendering π―
Combine optimistic updates with transitions for seamless UX:
import { useTransition } from 'react';
import { useMutation } from 'react-relay';
import graphql from 'babel-plugin-relay/macro';
function LikeButton({ post }) {
const [isPending, startTransition] = useTransition();
const [commitLike] = useMutation(graphql`
mutation LikeButtonMutation($postId: ID!) {
likePost(postId: $postId) {
post {
id
likeCount
viewerHasLiked
}
}
}
`);
const handleLike = () => {
startTransition(() => {
commitLike({
variables: { postId: post.id },
optimisticResponse: {
likePost: {
post: {
id: post.id,
likeCount: post.likeCount + 1,
viewerHasLiked: true
}
}
},
optimisticUpdater: (store) => {
const postRecord = store.get(post.id);
if (postRecord) {
postRecord.setValue(
post.likeCount + 1,
'likeCount'
);
postRecord.setValue(true, 'viewerHasLiked');
}
},
onError: (error) => {
console.error('Like failed:', error);
// Relay automatically reverts optimistic update
}
});
});
};
return (
<button
onClick={handleLike}
disabled={isPending || post.viewerHasLiked}
style={{ opacity: isPending ? 0.6 : 1 }}
>
{post.viewerHasLiked ? 'β€οΈ' : 'π€'} {post.likeCount}
</button>
);
}
Why use transitions here?
- Button responds instantly (optimistic update)
- UI stays interactive during network request
- If request fails, Relay reverts automatically
- Smooth visual feedback with opacity change
Common Mistakes to Avoid β οΈ
Mistake 1: Missing Suspense Boundaries
β Wrong:
function App() {
const data = useLazyLoadQuery(/* ... */);
return <div>{data.user.name}</div>;
}
// Crashes! No Suspense boundary to catch suspension
β Right:
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
);
}
function UserProfile() {
const data = useLazyLoadQuery(/* ... */);
return <div>{data.user.name}</div>;
}
Mistake 2: Too Many Suspense Boundaries
β Wrong:
// Every field causes separate suspension
<Suspense fallback="Loading name...">
<UserName user={data.user} />
</Suspense>
<Suspense fallback="Loading email...">
<UserEmail user={data.user} />
</Suspense>
β Right:
// Group related data under one boundary
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile user={data.user} />
</Suspense>
Mistake 3: Not Using Transitions for Non-Urgent Updates
β Wrong:
const handleFilter = (filter) => {
setFilter(filter); // Blocks UI while fetching
};
β Right:
const handleFilter = (filter) => {
startTransition(() => {
setFilter(filter); // Non-blocking, keeps old content visible
});
};
Mistake 4: Forgetting Fetch Policies
β Wrong:
useLazyLoadQuery(query, vars); // Always fetches, ignores cache
β Right:
useLazyLoadQuery(
query,
vars,
{ fetchPolicy: 'store-or-network' } // Use cache when available
);
Mistake 5: Suspending Without User Feedback
β Wrong:
<Suspense fallback={null}>
<SlowComponent />
</Suspense>
// User sees nothing, thinks app is frozen
β Right:
<Suspense fallback={<Skeleton />}>
<SlowComponent />
</Suspense>
// Clear visual indication of loading
Mistake 6: Over-Using @defer
β Wrong:
query MyQuery {
user {
id @defer
name @defer
email @defer
// Every field deferred = many waterfalls
}
}
β Right:
query MyQuery {
user {
id
name // Critical fields load together
email
...ExpensiveData_user @defer // Only defer expensive parts
}
}
π‘ Pro Tip: Use React DevTools Profiler to measure render times and identify which components benefit from Suspense boundaries and which don't.
Key Takeaways π
π Quick Reference Card
| Concept | Key Points | When to Use |
|---|---|---|
| Suspense | Declarative loading states, component suspends when data missing | Always with useLazyLoadQuery, usePreloadedQuery |
| Suspense Boundaries | Catches suspensions, shows fallback UI | Around components that fetch data |
| Concurrent Rendering | Interruptible rendering, time slicing | Automatic in React 18+ |
| Transitions | Mark updates as non-urgent, keep UI responsive | Navigation, filtering, search |
| @defer | Stream data in chunks, critical first | Large queries with varying importance |
| useQueryLoader | Preload queries before rendering | Tab navigation, hover preloading |
| useDeferredValue | Lag state updates for smooth typing | Search inputs, filters |
| fetchPolicy | Control cache behavior | store-or-network for fast loads |
π Mental Model
- Suspense = "Show this while waiting"
- Transition = "This update can wait"
- @defer = "Send this part later"
- Concurrent = "Don't freeze during work"
β‘ Performance Checklist
- β Use skeleton screens matching final layout
- β Preload data on hover/focus for instant navigation
- β Defer non-critical data with @defer directive
- β Wrap non-urgent updates in startTransition
- β Set appropriate fetchPolicy to leverage cache
- β Avoid suspending during urgent user interactions
- β Group related data under single boundaries
- β Profile with React DevTools to find bottlenecks
Did You Know? π€
React's Suspense was inspired by algebraic effectsβa programming concept from functional languages that allows "pausing" execution and resuming later. This is why components can "throw" promises and React "catches" them!
Relay was the first major library to adopt Suspense for data fetching, even before it was officially released. The collaboration between Relay and React teams shaped Suspense's final API.
Concurrent Rendering enables "speculative rendering"βReact can render multiple versions of the UI simultaneously and only commit the one that finishes first. This is why transitions feel so smooth!
Further Study π
- Relay Suspense Documentation: https://relay.dev/docs/guided-tour/rendering/loading-states/
- React Suspense Patterns: https://react.dev/reference/react/Suspense
- Concurrent Rendering Deep Dive: https://react.dev/blog/2022/03/29/react-v18#what-is-concurrent-react
You now have the tools to build sophisticated, performant UIs with Relay's Suspense integration. Practice combining these patternsβdeferred loading, transitions, and preloadingβto create experiences that feel instant even with complex data requirements. The key is understanding when to show loading states, when to keep old content visible, and when to preload data before users need it. Master these concepts, and your applications will feel native-app responsive! π