How to Implement React Suspense with Data Fetching
Introduction
React Suspense enables declarative loading states and better user experience during asynchronous operations. This guide shows how to implement it with data fetching.
Prerequisites
- React 18+
- Basic understanding of async operations
Step 1: Create Suspense-Compatible Data Fetcher
Create lib/suspenseCache.ts
:
type CacheEntry<T> = {
status: "pending" | "success" | "error";
data?: T;
error?: Error;
promise?: Promise<T>;
};
class SuspenseCache {
private cache = new Map<string, CacheEntry<any>>();
get<T>(key: string, fetcher: () => Promise<T>): T {
let entry = this.cache.get(key) as CacheEntry<T>;
if (!entry) {
// Create new cache entry
entry = { status: "pending" };
this.cache.set(key, entry);
entry.promise = fetcher()
.then(data => {
entry.status = "success";
entry.data = data;
return data;
})
.catch(error => {
entry.status = "error";
entry.error = error;
throw error;
});
}
if (entry.status === "pending") {
throw entry.promise; // Suspend component
}
if (entry.status === "error") {
throw entry.error;
}
return entry.data!;
}
invalidate(key: string) {
this.cache.delete(key);
}
clear() {
this.cache.clear();
}
}
export const suspenseCache = new SuspenseCache();
Step 2: Create Data Fetching Hook
Create hooks/useSuspenseQuery.ts
:
import { suspenseCache } from "@/lib/suspenseCache";
export function useSuspenseQuery<T>(
key: string | string[],
fetcher: () => Promise<T>
) {
const cacheKey = Array.isArray(key) ? key.join(":") : key;
return suspenseCache.get(cacheKey, fetcher);
}
// Utility function to create API fetchers
export function createFetcher<T>(url: string): () => Promise<T> {
return async () => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
};
}
Step 3: Create Suspense-Enabled Components
Create components/UserProfile.tsx
:
import { useSuspenseQuery, createFetcher } from '@/hooks/useSuspenseQuery';
interface User {
id: number;
name: string;
email: string;
phone: string;
website: string;
}
interface UserProfileProps {
userId: number;
}
export default function UserProfile({ userId }: UserProfileProps) {
// This will suspend the component until data is loaded
const user = useSuspenseQuery<User>(
['user', userId.toString()],
createFetcher(`https://jsonplaceholder.typicode.com/users/${userId}`)
);
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center mb-4">
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold">
{user.name.charAt(0)}
</div>
<div className="ml-4">
<h2 className="text-xl font-semibold">{user.name}</h2>
<p className="text-gray-600">{user.email}</p>
</div>
</div>
<div className="space-y-2">
<p><strong>Phone:</strong> {user.phone}</p>
<p><strong>Website:</strong> {user.website}</p>
</div>
</div>
);
}
Create components/UserPosts.tsx
:
import { useSuspenseQuery, createFetcher } from '@/hooks/useSuspenseQuery';
interface Post {
id: number;
title: string;
body: string;
}
interface UserPostsProps {
userId: number;
}
export default function UserPosts({ userId }: UserPostsProps) {
const posts = useSuspenseQuery<Post[]>(
['posts', userId.toString()],
createFetcher(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`)
);
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Posts ({posts.length})</h3>
{posts.map(post => (
<div key={post.id} className="bg-gray-50 rounded p-4">
<h4 className="font-medium mb-2">{post.title}</h4>
<p className="text-gray-700">{post.body}</p>
</div>
))}
</div>
);
}
Step 4: Create Loading Components
Create components/LoadingSpinner.tsx
:
export default function LoadingSpinner({ message = 'Loading...' }) {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mr-3"></div>
<span className="text-gray-600">{message}</span>
</div>
);
}
Create components/ErrorBoundary.tsx
:
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div className="bg-red-50 border border-red-200 rounded p-4">
<h3 className="text-red-800 font-medium">Something went wrong</h3>
<p className="text-red-600 mt-1">{this.state.error?.message}</p>
<button
onClick={() => this.setState({ hasError: false })}
className="mt-2 px-3 py-1 bg-red-600 text-white rounded text-sm"
>
Try Again
</button>
</div>
)
);
}
return this.props.children;
}
}
Step 5: Compose with Suspense
Create pages/UserPage.tsx
:
import { Suspense } from 'react';
import UserProfile from '@/components/UserProfile';
import UserPosts from '@/components/UserPosts';
import LoadingSpinner from '@/components/LoadingSpinner';
import ErrorBoundary from '@/components/ErrorBoundary';
interface UserPageProps {
userId: number;
}
export default function UserPage({ userId }: UserPageProps) {
return (
<div className="max-w-4xl mx-auto p-6">
<ErrorBoundary>
<Suspense fallback={<LoadingSpinner message="Loading user profile..." />}>
<UserProfile userId={userId} />
</Suspense>
</ErrorBoundary>
<div className="mt-8">
<ErrorBoundary>
<Suspense fallback={<LoadingSpinner message="Loading posts..." />}>
<UserPosts userId={userId} />
</Suspense>
</ErrorBoundary>
</div>
</div>
);
}
Step 6: Usage in App
import UserPage from '@/pages/UserPage';
export default function Home() {
return (
<main>
<h1 className="text-3xl font-bold text-center mb-8">User Dashboard</h1>
<UserPage userId={1} />
</main>
);
}
Summary
React Suspense provides declarative loading states and enables concurrent rendering, creating smoother user experiences. Combined with Error Boundaries, it offers robust data fetching patterns.