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

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 arrives
  • usePreloadedQuery: Reads preloaded data, suspends if not ready
  • useFragment: 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:

  1. Checks the store for cached data
  2. Suspends if data is missing (throws promise)
  3. Initiates network request in parallel
  4. Resolves suspension when data arrives and is written to store
  5. 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 useTransition or startTransition
  • 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:

  1. User types β†’ input updates immediately (urgent)
  2. startTransition marks results update as low-priority
  3. React keeps showing old results while preparing new ones
  4. New results appear when ready (smooth, no jarring spinner)
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: disposeQuery prevents 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 πŸ“š

  1. Relay Suspense Documentation: https://relay.dev/docs/guided-tour/rendering/loading-states/
  2. React Suspense Patterns: https://react.dev/reference/react/Suspense
  3. 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! πŸš€