Skip to content
Go back

How to Implement React Suspense with Data Fetching

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

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.


Share this post on:

Previous Post
Building a Drag-and-Drop Dashboard in React
Next Post
Handling Infinite Scroll with Intersection Observer in React