Skip to main content

React Concurrent Features: useTransition, useDeferredValue, and Suspense Patterns

Master React 18+ concurrent rendering with useTransition, useDeferredValue, and Suspense. Learn to build responsive UIs that stay interactive during expensive updates.

Table of Contents

Introduction

React 18 introduced concurrent rendering, a fundamental shift in how React handles updates. Unlike the synchronous rendering model of previous versions, concurrent rendering allows React to interrupt, pause, and resume work, enabling more responsive user interfaces even during expensive updates.

The concurrent features—useTransition, useDeferredValue, and Suspense—work together to solve a critical problem: keeping your UI responsive when performing expensive operations. Whether you’re filtering large lists, updating search results, or rendering complex components, these features ensure your application remains interactive and provides immediate feedback to users.

This guide will teach you how to leverage React’s concurrent features to build faster, more responsive applications. You’ll learn when and how to use each feature, understand their performance implications, and discover patterns for combining them effectively. By the end, you’ll be able to identify performance bottlenecks and apply the right concurrent feature to solve them.


Understanding Concurrent Rendering

Concurrent rendering is React’s ability to work on multiple versions of your UI simultaneously. Instead of blocking the main thread during updates, React can pause work, let the browser handle user interactions, and then resume rendering.

How Concurrent Rendering Works

In React 17 and earlier, rendering was synchronous and blocking. When React started rendering a component tree, it couldn’t be interrupted until the entire tree was rendered. This meant that expensive updates could freeze the UI, making it unresponsive.

React 18+ introduces concurrent rendering, which allows React to:

  1. Interrupt rendering: Pause work on low-priority updates
  2. Prioritize interactions: Handle user input immediately, even during renders
  3. Resume work: Continue rendering after handling urgent updates
  4. Cancel stale work: Abandon renders that are no longer needed
// React 17: Synchronous rendering (blocking)
function App() {
const [query, setQuery] = useState('');
const results = expensiveFilter(query); // Blocks UI until complete
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList items={results} />
</div>
);
}
// React 18+: Concurrent rendering (non-blocking)
function App() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query); // Non-blocking
const results = expensiveFilter(deferredQuery);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList items={results} />
</div>
);
}

Concurrent Features Overview

React provides three main concurrent features:

  1. useTransition: Marks state updates as non-urgent, allowing React to keep the UI responsive
  2. useDeferredValue: Defers updating a value, keeping the previous value visible while the new one computes
  3. Suspense: Handles loading states for async operations, enabling React to pause rendering until data is ready

These features work together to create a smooth, responsive user experience even when dealing with expensive operations.


The Problem: Blocking Updates

Before diving into solutions, let’s understand the problem concurrent features solve.

The Blocking Update Problem

When you update state that triggers expensive computations or renders, React must complete that work before handling other updates. This can cause:

  • Input lag: Typing feels sluggish because each keystroke triggers expensive work
  • Frozen UI: The interface becomes unresponsive during updates
  • Poor user experience: Users can’t interact with the app while updates process
// ❌ Problem: Blocking update
function SearchPage() {
const [query, setQuery] = useState('');
// This expensive filter blocks the UI on every keystroke
const filteredResults = useMemo(() => {
return largeDataset.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
);
}, [query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)} // Blocks on each keystroke
/>
<ResultsList items={filteredResults} />
</div>
);
}

In this example, every keystroke triggers a filter operation on a large dataset. While filtering happens, React can’t respond to other user interactions, making the input feel laggy.

When Updates Block the UI

Updates become blocking when they:

  • Process large amounts of data (filtering, sorting, searching)
  • Render many components (long lists, complex trees)
  • Perform expensive calculations (data transformations, aggregations)
  • Trigger cascading re-renders (updating shared state)

Concurrent features solve this by allowing React to prioritize urgent updates (like user input) over non-urgent ones (like updating search results).


useTransition: Marking Non-Urgent Updates

useTransition lets you mark state updates as non-urgent, allowing React to keep the UI responsive during those updates.

Basic Usage

import { useTransition, useState } from 'react';
function App() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('home');
function selectTab(nextTab: string) {
startTransition(() => {
setTab(nextTab); // Non-urgent update
});
}
return (
<div>
<button onClick={() => selectTab('home')}>
Home {isPending && '⏳'}
</button>
<button onClick={() => selectTab('about')}>
About {isPending && '⏳'}
</button>
<TabContent tab={tab} />
</div>
);
}

Understanding isPending

The isPending flag indicates whether a transition is currently in progress. Use it to:

  • Show loading indicators
  • Disable buttons during transitions
  • Provide visual feedback to users
function TabSwitcher() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('home');
function handleTabChange(newTab: string) {
startTransition(() => {
setTab(newTab);
});
}
return (
<div>
<button
onClick={() => handleTabChange('home')}
disabled={isPending} // Disable during transition
>
Home
</button>
<button
onClick={() => handleTabChange('about')}
disabled={isPending}
>
About
</button>
{isPending && <div>Loading...</div>} {/* Show loading state */}
<TabContent tab={tab} />
</div>
);
}

Real-World Example: Tab Navigation

function ProductPage() {
const [isPending, startTransition] = useTransition();
const [activeTab, setActiveTab] = useState('details');
function handleTabClick(tab: string) {
startTransition(() => {
setActiveTab(tab); // Heavy component rendering is non-urgent
});
}
return (
<div>
<div className="tabs">
<button
onClick={() => handleTabClick('details')}
className={activeTab === 'details' ? 'active' : ''}
>
Details
</button>
<button
onClick={() => handleTabClick('reviews')}
className={activeTab === 'reviews' ? 'active' : ''}
>
Reviews {isPending && '⏳'}
</button>
<button
onClick={() => handleTabClick('specs')}
className={activeTab === 'specs' ? 'active' : ''}
>
Specifications
</button>
</div>
<div className="tab-content">
{activeTab === 'details' && <ProductDetails />}
{activeTab === 'reviews' && <ProductReviews />} {/* Heavy component */}
{activeTab === 'specs' && <ProductSpecs />}
</div>
</div>
);
}

✅ When to Use useTransition

Use useTransition when:

  • Switching between views or tabs
  • Updating state that triggers expensive renders
  • Performing non-critical state updates
  • You want to keep the UI responsive during updates

❌ When NOT to Use useTransition

Avoid useTransition for:

  • Urgent updates (user input, form submissions)
  • Updates that should be immediate (showing error messages)
  • Simple state updates that don’t cause performance issues

useDeferredValue: Deferring Expensive Values

useDeferredValue defers updating a value, keeping the previous value visible while the new one computes. This is perfect for expensive computations that depend on user input.

Basic Usage

import { useDeferredValue, useState, useMemo } from 'react';
function SearchApp() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// Expensive computation uses deferred value
const results = useMemo(() => {
return searchLargeDataset(deferredQuery);
}, [deferredQuery]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)} // Immediate update
/>
<ResultsList items={results} /> {/* Uses deferred value */}
</div>
);
}

How useDeferredValue Works

useDeferredValue returns a deferred version of the value:

  1. Immediate updates: The original value updates immediately (e.g., input field)
  2. Deferred updates: The deferred value updates after urgent work completes
  3. Previous value: While computing, the previous value remains visible
function FilteredList() {
const [filter, setFilter] = useState('');
const deferredFilter = useDeferredValue(filter);
const filteredItems = useMemo(() => {
console.log('Filtering with:', deferredFilter);
return items.filter(item =>
item.name.toLowerCase().includes(deferredFilter.toLowerCase())
);
}, [deferredFilter]);
return (
<div>
{/* Input updates immediately - no lag */}
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search..."
/>
{/* List updates with deferred value - smooth */}
<ul>
{filteredItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}

Real-World Example: Search with Debouncing Effect

useDeferredValue provides a built-in debouncing effect without additional libraries:

function ProductSearch() {
const [searchTerm, setSearchTerm] = useState('');
const deferredSearchTerm = useDeferredValue(searchTerm);
const searchResults = useMemo(() => {
if (!deferredSearchTerm) return [];
// Expensive search operation
return products.filter(product =>
product.name.toLowerCase().includes(deferredSearchTerm.toLowerCase()) ||
product.description.toLowerCase().includes(deferredSearchTerm.toLowerCase())
);
}, [deferredSearchTerm]);
const isStale = searchTerm !== deferredSearchTerm;
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search products..."
className={isStale ? 'searching' : ''} // Visual feedback
/>
{isStale && (
<div className="search-indicator">
Searching for "{searchTerm}"...
</div>
)}
<div className="results">
{searchResults.length > 0 ? (
<ProductGrid products={searchResults} />
) : (
<p>No products found</p>
)}
</div>
</div>
);
}

Detecting Stale Values

You can detect when a deferred value is stale (not yet updated):

function SearchWithStaleIndicator() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// Check if value is stale
const isStale = query !== deferredQuery;
const results = useMemo(() => {
return performSearch(deferredQuery);
}, [deferredQuery]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{isStale && <span>Updating results...</span>}
<ResultsList items={results} />
</div>
);
}

✅ When to Use useDeferredValue

Use useDeferredValue when:

  • Filtering or searching large datasets
  • Performing expensive computations based on user input
  • Rendering complex components that depend on frequently changing values
  • You want to keep input responsive while computing results

❌ When NOT to Use useDeferredValue

Avoid useDeferredValue for:

  • Values that must update immediately (form validation, error messages)
  • Simple computations that don’t cause performance issues
  • Values that don’t trigger expensive operations

Suspense: Handling Async Operations

Suspense allows components to “wait” for something before rendering. It’s React’s way of handling async operations declaratively.

Basic Suspense Usage

import { Suspense } from 'react';
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<AsyncComponent />
</Suspense>
);
}
function AsyncComponent() {
// This component might suspend while fetching data
const data = use(fetchData()); // React 19 use() hook
return <div>{data.content}</div>;
}

Suspense with Data Fetching

// Data fetching function
async function fetchUserData(userId: string) {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
}
// Component that suspends
function UserProfile({ userId }: { userId: string }) {
const userData = use(fetchUserData(userId)); // Suspends until data loads
return (
<div>
<h1>{userData.name}</h1>
<p>{userData.email}</p>
</div>
);
}
// Wrapper with Suspense boundary
function UserPage({ userId }: { userId: string }) {
return (
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile userId={userId} />
</Suspense>
);
}

Multiple Suspense Boundaries

You can nest Suspense boundaries for granular loading states:

function Dashboard() {
return (
<div>
<Suspense fallback={<HeaderSkeleton />}>
<DashboardHeader />
</Suspense>
<div className="content">
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<DashboardChart />
</Suspense>
</div>
</div>
);
}

Suspense with React.lazy

Suspense works seamlessly with React.lazy for code splitting:

import { lazy, Suspense } from 'react';
// Lazy load heavy components
const HeavyComponent = lazy(() => import('./HeavyComponent'));
const AnotherComponent = lazy(() => import('./AnotherComponent'));
function App() {
return (
<Suspense fallback={<div>Loading component...</div>}>
<HeavyComponent />
<AnotherComponent />
</Suspense>
);
}

For more details on code splitting, see our guide on React Performance Optimization.

Error Boundaries with Suspense

Combine Suspense with Error Boundaries to handle loading and error states:

class ErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback: React.ReactNode },
{ hasError: boolean }
> {
constructor(props: any) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
function App() {
return (
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<LoadingSpinner />}>
<AsyncComponent />
</Suspense>
</ErrorBoundary>
);
}

✅ When to Use Suspense

Use Suspense when:

  • Fetching data asynchronously
  • Lazy loading components
  • Handling loading states declaratively
  • You want React to coordinate multiple async operations

⚠️ Suspense Limitations

  • Suspense doesn’t work with all data fetching libraries (works with React 19’s use() hook, Relay, and some others)
  • You need to use compatible data fetching solutions
  • Error handling requires Error Boundaries

Combining Concurrent Features

The real power of concurrent features emerges when you combine them. Let’s explore common patterns.

useTransition + Suspense

Combine useTransition with Suspense for smooth tab switching:

function TabContainer() {
const [isPending, startTransition] = useTransition();
const [activeTab, setActiveTab] = useState('home');
function handleTabChange(tab: string) {
startTransition(() => {
setActiveTab(tab); // Triggers Suspense if tab component suspends
});
}
return (
<div>
<TabButtons
activeTab={activeTab}
onTabChange={handleTabChange}
isPending={isPending}
/>
<Suspense fallback={<TabSkeleton />}>
<TabContent tab={activeTab} />
</Suspense>
</div>
);
}

useDeferredValue + Suspense

Combine useDeferredValue with Suspense for responsive search:

function SearchWithSuspense() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<Suspense fallback={<SearchResultsSkeleton />}>
<SearchResults query={deferredQuery} />
</Suspense>
</div>
);
}
function SearchResults({ query }: { query: string }) {
const results = use(fetchSearchResults(query)); // Suspends during fetch
return (
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
);
}

All Three Features Together

Here’s a comprehensive example combining all concurrent features:

function AdvancedSearchPage() {
const [isPending, startTransition] = useTransition();
const [searchTerm, setSearchTerm] = useState('');
const deferredSearchTerm = useDeferredValue(searchTerm);
function handleSearchChange(value: string) {
setSearchTerm(value); // Immediate update for input
startTransition(() => {
// Non-urgent: trigger search results update
});
}
const isStale = searchTerm !== deferredSearchTerm;
return (
<div>
<SearchInput
value={searchTerm}
onChange={handleSearchChange}
isSearching={isStale || isPending}
/>
<Suspense fallback={<SearchResultsSkeleton />}>
<SearchResults
query={deferredSearchTerm}
isStale={isStale}
/>
</Suspense>
</div>
);
}

Real-World Patterns and Examples

Let’s explore practical patterns you can use in your applications.

Pattern 1: Responsive List Filtering

function FilterableProductList({ products }: { products: Product[] }) {
const [filter, setFilter] = useState('');
const deferredFilter = useDeferredValue(filter);
const filteredProducts = useMemo(() => {
if (!deferredFilter) return products;
return products.filter(product =>
product.name.toLowerCase().includes(deferredFilter.toLowerCase()) ||
product.category.toLowerCase().includes(deferredFilter.toLowerCase())
);
}, [products, deferredFilter]);
const isStale = filter !== deferredFilter;
return (
<div>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter products..."
className={isStale ? 'filtering' : ''}
/>
{isStale && (
<div className="filter-indicator">
Filtering... ({filteredProducts.length} results)
</div>
)}
<ProductGrid products={filteredProducts} />
</div>
);
}

Pattern 2: Smooth Tab Navigation

function ProductTabs({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition();
const [activeTab, setActiveTab] = useState('overview');
function handleTabClick(tab: string) {
if (tab === activeTab) return;
startTransition(() => {
setActiveTab(tab);
});
}
return (
<div>
<div className="tabs">
{['overview', 'specs', 'reviews', 'related'].map(tab => (
<button
key={tab}
onClick={() => handleTabClick(tab)}
className={activeTab === tab ? 'active' : ''}
disabled={isPending}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
{isPending && activeTab === tab && ' ⏳'}
</button>
))}
</div>
<Suspense fallback={<TabContentSkeleton />}>
<TabContent tab={activeTab} productId={productId} />
</Suspense>
</div>
);
}

Pattern 3: Optimistic Updates with Transitions

function TodoList() {
const [isPending, startTransition] = useTransition();
const [todos, setTodos] = useState<Todo[]>([]);
function handleAddTodo(text: string) {
const newTodo: Todo = {
id: Date.now(),
text,
completed: false,
};
// Optimistic update: add immediately
setTodos(prev => [...prev, newTodo]);
// Non-urgent: sync with server
startTransition(async () => {
try {
await saveTodoToServer(newTodo);
} catch (error) {
// Rollback on error
setTodos(prev => prev.filter(t => t.id !== newTodo.id));
alert('Failed to save todo');
}
});
}
return (
<div>
<TodoInput onSubmit={handleAddTodo} disabled={isPending} />
<TodoList items={todos} />
{isPending && <div>Saving...</div>}
</div>
);
}

Pattern 4: Debounced Search with Suspense

function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<div>
<SearchInput
value={query}
onChange={setQuery}
placeholder="Search..."
/>
{query && (
<Suspense
key={deferredQuery} // Re-suspend when query changes
fallback={<SearchResultsSkeleton />}
>
<SearchResults query={deferredQuery} />
</Suspense>
)}
</div>
);
}
function SearchResults({ query }: { query: string }) {
// This suspends until data is fetched
const results = use(fetchSearchResults(query));
if (results.length === 0) {
return <div>No results found for "{query}"</div>;
}
return (
<div>
<h2>Results for "{query}"</h2>
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
</div>
);
}

Performance Considerations

Understanding performance implications helps you use concurrent features effectively.

Measuring Performance Impact

Use React DevTools Profiler to measure the impact of concurrent features:

// Before: Blocking update
function BeforeOptimization() {
const [query, setQuery] = useState('');
const results = expensiveFilter(query); // Blocks UI
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList items={results} />
</div>
);
}
// After: Non-blocking with useDeferredValue
function AfterOptimization() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const results = expensiveFilter(deferredQuery); // Non-blocking
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList items={results} />
</div>
);
}

When Concurrent Features Help

Concurrent features provide the most benefit when:

  • Large datasets: Processing thousands of items
  • Complex renders: Rendering many components
  • Frequent updates: State changes happen rapidly
  • User interactions: Users need immediate feedback

When They Don’t Help

Concurrent features won’t help if:

  • Bottleneck is elsewhere: Network latency, database queries
  • Updates are already fast: Simple state updates
  • Overhead exceeds benefit: Very small datasets

Performance Best Practices

  1. Profile first: Use React DevTools to identify bottlenecks
  2. Measure impact: Compare before/after performance
  3. Use sparingly: Don’t add unnecessary complexity
  4. Combine with other optimizations: Use with memoization, code splitting

For comprehensive performance optimization strategies, see our guide on React Performance Optimization.


Common Pitfalls and Anti-Patterns

Avoid these common mistakes when using concurrent features.

❌ Anti-Pattern 1: Using useTransition for Urgent Updates

// ❌ Wrong: User input should be immediate
function SearchInput() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState('');
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
startTransition(() => {
setQuery(e.target.value); // Input feels laggy!
});
}
return <input value={query} onChange={handleChange} />;
}
// ✅ Correct: Keep input immediate, defer expensive operations
function SearchInput() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => expensiveFilter(deferredQuery), [deferredQuery]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList items={results} />
</>
);
}

❌ Anti-Pattern 2: Overusing useDeferredValue

// ❌ Wrong: Unnecessary deferral
function SimpleCounter() {
const [count, setCount] = useState(0);
const deferredCount = useDeferredValue(count); // Unnecessary!
return <div>{deferredCount}</div>; // Simple update doesn't need deferral
}
// ✅ Correct: Only defer expensive operations
function SimpleCounter() {
const [count, setCount] = useState(0);
return <div>{count}</div>; // Simple updates are fine
}

❌ Anti-Pattern 3: Missing Suspense Boundaries

// ❌ Wrong: No Suspense boundary
function App() {
return <AsyncComponent />; // Will throw if component suspends
}
// ✅ Correct: Wrap with Suspense
function App() {
return (
<Suspense fallback={<Loading />}>
<AsyncComponent />
</Suspense>
);
}

❌ Anti-Pattern 4: Not Handling Stale Values

// ❌ Wrong: No indication that value is stale
function SearchResults({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
const results = search(deferredQuery);
return <div>{results.length} results</div>; // User doesn't know it's stale
}
// ✅ Correct: Show stale indicator
function SearchResults({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
const results = search(deferredQuery);
const isStale = query !== deferredQuery;
return (
<div>
{isStale && <div>Updating results...</div>}
<div>{results.length} results</div>
</div>
);
}

⚠️ Common Gotchas

  1. Transitions don’t prevent re-renders: They just mark updates as non-urgent
  2. Deferred values still update: They just update later, not never
  3. Suspense requires compatible libraries: Not all data fetching works with Suspense
  4. Nested Suspense boundaries: Each boundary handles its own loading state

Best Practices

Follow these best practices to get the most out of concurrent features.

✅ Best Practice 1: Use useTransition for View Changes

// ✅ Good: Tab switching is non-urgent
function Tabs() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('home');
function handleTabChange(newTab: string) {
startTransition(() => {
setTab(newTab);
});
}
return (
<>
<TabButtons activeTab={tab} onChange={handleTabChange} />
{isPending && <LoadingIndicator />}
<TabContent tab={tab} />
</>
);
}

✅ Best Practice 2: Use useDeferredValue for Expensive Computations

// ✅ Good: Defer expensive filtering
function ProductList({ products }: { products: Product[] }) {
const [filter, setFilter] = useState('');
const deferredFilter = useDeferredValue(filter);
const filtered = useMemo(() => {
return products.filter(p =>
p.name.toLowerCase().includes(deferredFilter.toLowerCase())
);
}, [products, deferredFilter]);
return (
<>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
<ProductGrid products={filtered} />
</>
);
}

✅ Best Practice 3: Provide Meaningful Fallbacks

// ✅ Good: Specific fallback for each Suspense boundary
function Dashboard() {
return (
<div>
<Suspense fallback={<HeaderSkeleton />}>
<DashboardHeader />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<DashboardChart />
</Suspense>
</div>
);
}

✅ Best Practice 4: Combine with Error Boundaries

// ✅ Good: Handle both loading and error states
function App() {
return (
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<LoadingPage />}>
<AsyncContent />
</Suspense>
</ErrorBoundary>
);
}

✅ Best Practice 5: Show Loading States

// ✅ Good: Provide visual feedback
function SearchPage() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
className={isStale ? 'searching' : ''}
/>
{(isPending || isStale) && <SearchIndicator />}
<SearchResults query={deferredQuery} />
</div>
);
}

💡 Pro Tips

  1. Start with profiling: Use React DevTools to identify bottlenecks before optimizing
  2. Measure real impact: Test on real devices, not just development
  3. Combine features: Use useTransition + Suspense or useDeferredValue + Suspense for best results
  4. Keep fallbacks simple: Fast, lightweight loading states improve perceived performance
  5. Test edge cases: Ensure your app handles rapid state changes gracefully

Conclusion

React’s concurrent features—useTransition, useDeferredValue, and Suspense—transform how we build responsive user interfaces. By understanding when and how to use each feature, you can create applications that remain interactive even during expensive operations.

Key Takeaways:

  • useTransition: Mark non-urgent updates to keep the UI responsive during view changes
  • useDeferredValue: Defer expensive computations to maintain input responsiveness
  • Suspense: Handle async operations declaratively with proper loading states
  • Combine features: Use them together for maximum impact
  • Profile first: Measure performance before and after optimization

Next Steps:

  1. Identify performance bottlenecks in your React applications
  2. Apply concurrent features where they provide the most benefit
  3. Test thoroughly to ensure smooth user experiences
  4. Monitor performance in production to validate improvements

For more React optimization techniques, explore our guides on React Performance Optimization and React 19 Features. To learn about managing state effectively, see our State Management guide.

Remember: concurrent features are powerful tools, but they’re not a silver bullet. Use them strategically where they provide real value, and always measure the impact on your users’ experience.