React suspense and concurrent features best practices

Engineering Manager
August 15, 2024
Updated on December 16, 2024
0 MIN READ
#frontend#tailwind#web3#typescript#next-js

React Suspense and Concurrent Features Best Practices

Introduction

React's Concurrent Features, introduced in React 18, bring powerful new capabilities for improving user experience through smoother rendering, prioritized updates, and seamless data fetching. Among these, React Suspense is a key feature that allows developers to declaratively manage loading states while keeping UI responsive.

In this post, we'll explore best practices for using Suspense alongside other Concurrent Features like startTransition, useDeferredValue, and streaming server-side rendering (SSR). Whether you're integrating Suspense with data fetching libraries like Relay or React Query or optimizing rendering performance, these guidelines will help you build more efficient and user-friendly applications.


1. Understanding Suspense and Its Core Use Cases

Suspense enables components to "wait" for asynchronous operations (like data fetching or lazy-loaded components) before rendering. Instead of manually managing loading states, Suspense provides a fallback UI while the data resolves.

Best Practices:

Use Suspense for Data Fetching with Compatible Libraries

Not all data-fetching solutions support Suspense out of the box. Libraries like React Query, Relay, and SWR have Suspense integrations.

For example, with React Query:

import { Suspense } from 'react'; import { useSuspenseQuery } from '@tanstack/react-query'; const fetchPosts = async () => { const res = await fetch('https://api.example.com/posts'); return res.json(); }; function Posts() { const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: fetchPosts, }); return ( <ul> {data.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> ); } function App() { return ( <Suspense fallback={<div>Loading posts...</div>}> <Posts /> </Suspense> ); }

Avoid Nesting Suspense Unnecessarily

While you can nest Suspense boundaries, excessive nesting can lead to a "waterfall" loading pattern. Instead, structure Suspense at logical component boundaries.


2. Combining Suspense with startTransition for Smoother Transitions

Concurrent React introduces startTransition to mark non-urgent state updates, preventing UI freezes during heavy rendering.

Best Practices:

Defer Non-Critical Updates

Use startTransition when switching between views or filtering data to keep the UI responsive:

import { useState, startTransition } from 'react'; function SearchComponent() { const [query, setQuery] = useState(''); const [filteredData, setFilteredData] = useState([]); const handleSearch = (e) => { const newQuery = e.target.value; setQuery(newQuery); startTransition(() => { // Non-urgent filtering const filtered = largeDataset.filter(item => item.name.includes(newQuery) ); setFilteredData(filtered); }); }; return ( <div> <input value={query} onChange={handleSearch} /> <Suspense fallback={<div>Filtering...</div>}> <Results data={filteredData} /> </Suspense> </div> ); }

Prioritize User Input with useDeferredValue

For deferred rendering of derived values (like search results), useDeferredValue helps avoid input lag:

import { useDeferredValue } from 'react'; function Results({ data }) { const deferredData = useDeferredValue(data); return <List items={deferredData} />; }

3. Optimizing SSR with Streaming and Suspense

React 18's streaming SSR allows sending HTML in chunks, improving perceived performance. Suspense plays a key role here by defining fallbacks for server-rendered async components.

Best Practices:

Use renderToPipeableStream for Node.js SSR

For Node.js environments, leverage streaming with react-dom/server:

import { renderToPipeableStream } from 'react-dom/server'; app.get('/', (req, res) => { const { pipe } = renderToPipeableStream( <App />, { bootstrapScripts: ['/main.js'], onShellReady() { res.setHeader('Content-type', 'text/html'); pipe(res); }, } ); });

Wrap Lazy-Loaded Components in Suspense

For code-split components, ensure Suspense boundaries are in place:

const LazyComponent = React.lazy(() => import('./LazyComponent')); function App() { return ( <Suspense fallback={<Spinner />}> <LazyComponent /> </Suspense> ); }

4. Error Handling with Suspense and Error Boundaries

Suspense doesn’t handle errors by default—pair it with Error Boundaries to catch failures in async operations.

Best Practices:

Wrap Suspense in Error Boundaries

import { ErrorBoundary } from 'react-error-boundary'; function ErrorFallback({ error }) { return <div>Error: {error.message}</div>; } function App() { return ( <ErrorBoundary FallbackComponent={ErrorFallback}> <Suspense fallback={<div>Loading...</div>}> <Posts /> </Suspense> </ErrorBoundary> ); }

Retry Failed Operations

Provide a recovery mechanism (e.g., a "Retry" button in the error boundary).


Conclusion

React Suspense and Concurrent Features unlock smoother, more responsive applications by simplifying async rendering and prioritizing critical updates. Key takeaways:

  • Use Suspense with compatible data-fetching libraries.
  • Combine startTransition and useDeferredValue for non-blocking UI.
  • Optimize SSR with streaming and Suspense fallbacks.
  • Always pair Suspense with Error Boundaries for robustness.

By following these best practices, you can harness the full power of Concurrent React while maintaining a clean and maintainable codebase. Happy coding! 🚀

Share this article