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:
jsx1function createResource(promise) {2 let status = 'pending';3 let result;45 const suspender = promise.then(6 (data) => {7 status = 'success';8 result = data;9 },10 (error) => {11 status = 'error';12 result = error;13 }14 );1516 return {17 read() {18 switch (status) {19 case 'pending':20 throw suspender; // Suspense catches this21 case 'error':22 throw result; // Error boundary catches this23 case 'success':24 return result;25 }26 }27 };28}2930// Usage31const userResource = createResource(32 fetch('/api/user').then(r => r.json())33);3435function UserProfile() {36 const user = userResource.read();37 return <h1>{user.name}</h1>;38}
Parameterized Resources
Create resources dynamically based on parameters:
jsx1// Resource factory2function createUserResource(userId) {3 return createResource(4 fetch(`/api/users/${userId}`).then(r => r.json())5 );6}78// Cache resources to avoid refetching9const resourceCache = new Map();1011function getUserResource(userId) {12 if (!resourceCache.has(userId)) {13 resourceCache.set(userId, createUserResource(userId));14 }15 return resourceCache.get(userId);16}1718// Component usage19function UserProfile({ userId }) {20 const resource = getUserResource(userId);21 const user = resource.read();2223 return <div>{user.name}</div>;24}
Render-as-You-Fetch Pattern
Start fetching before rendering:
jsx1// Start fetching at route transition2function onNavigate(route) {3 if (route === '/profile') {4 // Kick off fetches immediately5 prefetchProfileData();6 }7}89function prefetchProfileData() {10 return {11 user: createResource(fetchUser()),12 posts: createResource(fetchPosts()),13 friends: createResource(fetchFriends())14 };15}1617// Then render with pre-fetched resources18function ProfilePage({ resources }) {19 return (20 <Suspense fallback={<ProfileSkeleton />}>21 <ProfileContent resources={resources} />22 </Suspense>23 );24}2526function ProfileContent({ resources }) {27 const user = resources.user.read();28 const posts = resources.posts.read();29 const friends = resources.friends.read();3031 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:
jsx1import { QueryClient, QueryClientProvider } from '@tanstack/react-query';2import { useSuspenseQuery, useSuspenseQueries } from '@tanstack/react-query';34const queryClient = new QueryClient({5 defaultOptions: {6 queries: {7 staleTime: 60 * 1000, // 1 minute8 retry: 2,9 }10 }11});1213function 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}2425// Single query26function UserProfile({ userId }) {27 const { data: user } = useSuspenseQuery({28 queryKey: ['user', userId],29 queryFn: () => fetchUser(userId)30 });3132 return <div>{user.name}</div>;33}3435// Multiple parallel queries36function 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 });4849 return (50 <>51 <Header user={user} notifications={notifications} />52 <Stats data={stats} />53 </>54 );55}
Skeleton Loading States
Create meaningful loading placeholders:
jsx1// Skeleton components2function 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}1112function 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}2122// CSS for skeleton animation23const 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 }3536 @keyframes shimmer {37 0% { background-position: 200% 0; }38 100% { background-position: -200% 0; }39 }4041 .skeleton.avatar {42 width: 80px;43 height: 80px;44 border-radius: 50%;45 }4647 .skeleton.text-line {48 height: 16px;49 margin: 8px 0;50 }5152 .skeleton.text-line.short {53 width: 60%;54 }55`;
Error Recovery Patterns
Handle errors gracefully with reset capabilities:
jsx1import { ErrorBoundary } from 'react-error-boundary';2import { useQueryErrorResetBoundary } from '@tanstack/react-query';34function 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}1314function App() {15 const { reset } = useQueryErrorResetBoundary();1617 return (18 <ErrorBoundary19 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:
jsx1import { useMutation, useQueryClient } from '@tanstack/react-query';2import { useOptimistic } from 'react';34function TodoList() {5 const queryClient = useQueryClient();6 const { data: todos } = useSuspenseQuery({7 queryKey: ['todos'],8 queryFn: fetchTodos9 });1011 const [optimisticTodos, addOptimisticTodo] = useOptimistic(12 todos,13 (state, newTodo) => [...state, { ...newTodo, pending: true }]14 );1516 const mutation = useMutation({17 mutationFn: addTodo,18 onMutate: async (newTodo) => {19 addOptimisticTodo(newTodo);20 },21 onSettled: () => {22 queryClient.invalidateQueries({ queryKey: ['todos'] });23 }24 });2526 return (27 <ul>28 {optimisticTodos.map(todo => (29 <li30 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:
jsx1function InfinitePostList() {2 const {3 data,4 fetchNextPage,5 hasNextPage,6 isFetchingNextPage7 } = useSuspenseInfiniteQuery({8 queryKey: ['posts'],9 queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),10 getNextPageParam: (lastPage) => lastPage.nextCursor11 });1213 const allPosts = data.pages.flatMap(page => page.posts);1415 return (16 <div>17 {allPosts.map(post => (18 <PostCard key={post.id} post={post} />19 ))}2021 {hasNextPage && (22 <button23 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:
jsx1function SearchResults({ query }) {2 // Don't fetch if query is too short3 const { data } = useSuspenseQuery({4 queryKey: ['search', query],5 queryFn: () => search(query),6 enabled: query.length >= 3 // Only fetch when enabled7 });89 if (query.length < 3) {10 return <p>Type at least 3 characters to search</p>;11 }1213 return (14 <ul>15 {data.map(result => (16 <li key={result.id}>{result.title}</li>17 ))}18 </ul>19 );20}2122// Wrap in Suspense23function Search() {24 const [query, setQuery] = useState('');25 const debouncedQuery = useDebounce(query, 300);2627 return (28 <div>29 <input30 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:
jsx1function UserPosts({ userId }) {2 // First query3 const { data: user } = useSuspenseQuery({4 queryKey: ['user', userId],5 queryFn: () => fetchUser(userId)6 });78 // Dependent query - only runs when user is available9 const { data: posts } = useSuspenseQuery({10 queryKey: ['posts', user.id],11 queryFn: () => fetchPostsByUser(user.id),12 // This query depends on user being available13 });1415 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:
jsx1import { render, screen, waitFor } from '@testing-library/react';2import { Suspense } from 'react';34// Mock the data fetching5jest.mock('./api', () => ({6 fetchUser: jest.fn()7}));89test('shows loading then user data', async () => {10 fetchUser.mockResolvedValue({ name: 'John' });1112 render(13 <Suspense fallback={<div>Loading...</div>}>14 <UserProfile userId={1} />15 </Suspense>16 );1718 // Initially shows loading19 expect(screen.getByText('Loading...')).toBeInTheDocument();2021 // Then shows user22 await waitFor(() => {23 expect(screen.getByText('John')).toBeInTheDocument();24 });25});
Summary
Best practices for Suspense data fetching:
- Use render-as-you-fetch - Start fetching before rendering
- Nest Suspense boundaries - Granular loading states
- Use proper skeletons - Meaningful loading placeholders
- Handle errors - Error boundaries with reset
- Cache wisely - Avoid refetching unchanged data
- Fetch in parallel - Don't create waterfalls
- Consider libraries - React Query, SWR provide great DX
- Preload on interaction - Hover/focus to start fetch early
Next, let's build a complete data dashboard with these patterns!