20 minlesson

Suspense Patterns and Best Practices

Suspense Patterns and Best Practices

Let's explore advanced patterns for building robust data-fetching applications with Suspense.

The Resource Pattern

Create a "resource" that integrates with Suspense:

jsx
1function createResource(promise) {
2 let status = 'pending';
3 let result;
4
5 const suspender = promise.then(
6 (data) => {
7 status = 'success';
8 result = data;
9 },
10 (error) => {
11 status = 'error';
12 result = error;
13 }
14 );
15
16 return {
17 read() {
18 switch (status) {
19 case 'pending':
20 throw suspender; // Suspense catches this
21 case 'error':
22 throw result; // Error boundary catches this
23 case 'success':
24 return result;
25 }
26 }
27 };
28}
29
30// Usage
31const userResource = createResource(
32 fetch('/api/user').then(r => r.json())
33);
34
35function UserProfile() {
36 const user = userResource.read();
37 return <h1>{user.name}</h1>;
38}

Parameterized Resources

Create resources dynamically based on parameters:

jsx
1// Resource factory
2function createUserResource(userId) {
3 return createResource(
4 fetch(`/api/users/${userId}`).then(r => r.json())
5 );
6}
7
8// Cache resources to avoid refetching
9const resourceCache = new Map();
10
11function getUserResource(userId) {
12 if (!resourceCache.has(userId)) {
13 resourceCache.set(userId, createUserResource(userId));
14 }
15 return resourceCache.get(userId);
16}
17
18// Component usage
19function UserProfile({ userId }) {
20 const resource = getUserResource(userId);
21 const user = resource.read();
22
23 return <div>{user.name}</div>;
24}

Render-as-You-Fetch Pattern

Start fetching before rendering:

jsx
1// Start fetching at route transition
2function onNavigate(route) {
3 if (route === '/profile') {
4 // Kick off fetches immediately
5 prefetchProfileData();
6 }
7}
8
9function prefetchProfileData() {
10 return {
11 user: createResource(fetchUser()),
12 posts: createResource(fetchPosts()),
13 friends: createResource(fetchFriends())
14 };
15}
16
17// Then render with pre-fetched resources
18function ProfilePage({ resources }) {
19 return (
20 <Suspense fallback={<ProfileSkeleton />}>
21 <ProfileContent resources={resources} />
22 </Suspense>
23 );
24}
25
26function ProfileContent({ resources }) {
27 const user = resources.user.read();
28 const posts = resources.posts.read();
29 const friends = resources.friends.read();
30
31 return (
32 <>
33 <UserHeader user={user} />
34 <PostList posts={posts} />
35 <FriendsList friends={friends} />
36 </>
37 );
38}

Suspense with React Query

React Query provides excellent Suspense support:

jsx
1import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2import { useSuspenseQuery, useSuspenseQueries } from '@tanstack/react-query';
3
4const queryClient = new QueryClient({
5 defaultOptions: {
6 queries: {
7 staleTime: 60 * 1000, // 1 minute
8 retry: 2,
9 }
10 }
11});
12
13function App() {
14 return (
15 <QueryClientProvider client={queryClient}>
16 <ErrorBoundary fallback={<ErrorPage />}>
17 <Suspense fallback={<AppSkeleton />}>
18 <MainApp />
19 </Suspense>
20 </ErrorBoundary>
21 </QueryClientProvider>
22 );
23}
24
25// Single query
26function UserProfile({ userId }) {
27 const { data: user } = useSuspenseQuery({
28 queryKey: ['user', userId],
29 queryFn: () => fetchUser(userId)
30 });
31
32 return <div>{user.name}</div>;
33}
34
35// Multiple parallel queries
36function Dashboard() {
37 const [
38 { data: user },
39 { data: stats },
40 { data: notifications }
41 ] = useSuspenseQueries({
42 queries: [
43 { queryKey: ['user'], queryFn: fetchUser },
44 { queryKey: ['stats'], queryFn: fetchStats },
45 { queryKey: ['notifications'], queryFn: fetchNotifications }
46 ]
47 });
48
49 return (
50 <>
51 <Header user={user} notifications={notifications} />
52 <Stats data={stats} />
53 </>
54 );
55}

Skeleton Loading States

Create meaningful loading placeholders:

jsx
1// Skeleton components
2function ProfileSkeleton() {
3 return (
4 <div className="profile-skeleton">
5 <div className="skeleton avatar" />
6 <div className="skeleton text-line" />
7 <div className="skeleton text-line short" />
8 </div>
9 );
10}
11
12function CardSkeleton() {
13 return (
14 <div className="card-skeleton">
15 <div className="skeleton image" />
16 <div className="skeleton text-line" />
17 <div className="skeleton text-line" />
18 </div>
19 );
20}
21
22// CSS for skeleton animation
23const skeletonStyles = `
24 .skeleton {
25 background: linear-gradient(
26 90deg,
27 #f0f0f0 25%,
28 #e0e0e0 50%,
29 #f0f0f0 75%
30 );
31 background-size: 200% 100%;
32 animation: shimmer 1.5s infinite;
33 border-radius: 4px;
34 }
35
36 @keyframes shimmer {
37 0% { background-position: 200% 0; }
38 100% { background-position: -200% 0; }
39 }
40
41 .skeleton.avatar {
42 width: 80px;
43 height: 80px;
44 border-radius: 50%;
45 }
46
47 .skeleton.text-line {
48 height: 16px;
49 margin: 8px 0;
50 }
51
52 .skeleton.text-line.short {
53 width: 60%;
54 }
55`;

Error Recovery Patterns

Handle errors gracefully with reset capabilities:

jsx
1import { ErrorBoundary } from 'react-error-boundary';
2import { useQueryErrorResetBoundary } from '@tanstack/react-query';
3
4function ErrorFallback({ error, resetErrorBoundary }) {
5 return (
6 <div className="error-container">
7 <h2>Something went wrong</h2>
8 <pre>{error.message}</pre>
9 <button onClick={resetErrorBoundary}>Try again</button>
10 </div>
11 );
12}
13
14function App() {
15 const { reset } = useQueryErrorResetBoundary();
16
17 return (
18 <ErrorBoundary
19 onReset={reset}
20 fallbackRender={ErrorFallback}
21 >
22 <Suspense fallback={<AppSkeleton />}>
23 <MainContent />
24 </Suspense>
25 </ErrorBoundary>
26 );
27}

Optimistic Updates with Suspense

Show optimistic UI while mutations complete:

jsx
1import { useMutation, useQueryClient } from '@tanstack/react-query';
2import { useOptimistic } from 'react';
3
4function TodoList() {
5 const queryClient = useQueryClient();
6 const { data: todos } = useSuspenseQuery({
7 queryKey: ['todos'],
8 queryFn: fetchTodos
9 });
10
11 const [optimisticTodos, addOptimisticTodo] = useOptimistic(
12 todos,
13 (state, newTodo) => [...state, { ...newTodo, pending: true }]
14 );
15
16 const mutation = useMutation({
17 mutationFn: addTodo,
18 onMutate: async (newTodo) => {
19 addOptimisticTodo(newTodo);
20 },
21 onSettled: () => {
22 queryClient.invalidateQueries({ queryKey: ['todos'] });
23 }
24 });
25
26 return (
27 <ul>
28 {optimisticTodos.map(todo => (
29 <li
30 key={todo.id}
31 style={{ opacity: todo.pending ? 0.5 : 1 }}
32 >
33 {todo.title}
34 </li>
35 ))}
36 <AddTodoForm onAdd={mutation.mutate} />
37 </ul>
38 );
39}

Pagination with Suspense

Handle paginated data:

jsx
1function InfinitePostList() {
2 const {
3 data,
4 fetchNextPage,
5 hasNextPage,
6 isFetchingNextPage
7 } = useSuspenseInfiniteQuery({
8 queryKey: ['posts'],
9 queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
10 getNextPageParam: (lastPage) => lastPage.nextCursor
11 });
12
13 const allPosts = data.pages.flatMap(page => page.posts);
14
15 return (
16 <div>
17 {allPosts.map(post => (
18 <PostCard key={post.id} post={post} />
19 ))}
20
21 {hasNextPage && (
22 <button
23 onClick={() => fetchNextPage()}
24 disabled={isFetchingNextPage}
25 >
26 {isFetchingNextPage ? 'Loading...' : 'Load More'}
27 </button>
28 )}
29 </div>
30 );
31}

Conditional Fetching

Only fetch when conditions are met:

jsx
1function SearchResults({ query }) {
2 // Don't fetch if query is too short
3 const { data } = useSuspenseQuery({
4 queryKey: ['search', query],
5 queryFn: () => search(query),
6 enabled: query.length >= 3 // Only fetch when enabled
7 });
8
9 if (query.length < 3) {
10 return <p>Type at least 3 characters to search</p>;
11 }
12
13 return (
14 <ul>
15 {data.map(result => (
16 <li key={result.id}>{result.title}</li>
17 ))}
18 </ul>
19 );
20}
21
22// Wrap in Suspense
23function Search() {
24 const [query, setQuery] = useState('');
25 const debouncedQuery = useDebounce(query, 300);
26
27 return (
28 <div>
29 <input
30 value={query}
31 onChange={e => setQuery(e.target.value)}
32 placeholder="Search..."
33 />
34 <Suspense fallback={<SearchSkeleton />}>
35 <SearchResults query={debouncedQuery} />
36 </Suspense>
37 </div>
38 );
39}

Dependent Queries

Fetch data that depends on other data:

jsx
1function UserPosts({ userId }) {
2 // First query
3 const { data: user } = useSuspenseQuery({
4 queryKey: ['user', userId],
5 queryFn: () => fetchUser(userId)
6 });
7
8 // Dependent query - only runs when user is available
9 const { data: posts } = useSuspenseQuery({
10 queryKey: ['posts', user.id],
11 queryFn: () => fetchPostsByUser(user.id),
12 // This query depends on user being available
13 });
14
15 return (
16 <div>
17 <h1>{user.name}'s Posts</h1>
18 <PostList posts={posts} />
19 </div>
20 );
21}

Testing Suspense Components

Test components that use Suspense:

jsx
1import { render, screen, waitFor } from '@testing-library/react';
2import { Suspense } from 'react';
3
4// Mock the data fetching
5jest.mock('./api', () => ({
6 fetchUser: jest.fn()
7}));
8
9test('shows loading then user data', async () => {
10 fetchUser.mockResolvedValue({ name: 'John' });
11
12 render(
13 <Suspense fallback={<div>Loading...</div>}>
14 <UserProfile userId={1} />
15 </Suspense>
16 );
17
18 // Initially shows loading
19 expect(screen.getByText('Loading...')).toBeInTheDocument();
20
21 // Then shows user
22 await waitFor(() => {
23 expect(screen.getByText('John')).toBeInTheDocument();
24 });
25});

Summary

Best practices for Suspense data fetching:

  1. Use render-as-you-fetch - Start fetching before rendering
  2. Nest Suspense boundaries - Granular loading states
  3. Use proper skeletons - Meaningful loading placeholders
  4. Handle errors - Error boundaries with reset
  5. Cache wisely - Avoid refetching unchanged data
  6. Fetch in parallel - Don't create waterfalls
  7. Consider libraries - React Query, SWR provide great DX
  8. Preload on interaction - Hover/focus to start fetch early

Next, let's build a complete data dashboard with these patterns!