React’s concurrent features represent the biggest paradigm shift in React development since hooks. While most developers focus on the performance benefits of features like automatic batching and Suspense, the real revolution lies in how these features fundamentally change our approach to state management.
If you’re still thinking about state the “old way,” you’re missing out on simpler, more resilient patterns that concurrent React enables. Let’s explore this transformation.
The Old Mental Model
Traditionally, we’ve thought about React state management in terms of synchronous, predictable updates:
function OrderForm() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
const handleSubmit = async (orderData) => {
setLoading(true);
setError(null);
try {
const result = await submitOrder(orderData);
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
{loading && <Spinner />}
{error && <ErrorMessage error={error} />}
{data && <SuccessMessage data={data} />}
{/* form fields */}
</form>
);
}
This pattern works, but it forces us to manually orchestrate loading states, error boundaries, and data fetching. We’re essentially building our own state machines for every async operation.
Enter Concurrent Features
React’s concurrent features introduce three game-changing concepts that reshape state management:
1. Automatic Batching
React 18+ automatically batches multiple state updates, even across async boundaries:
// Before React 18: Multiple renders
setTimeout(() => {
setCount((c) => c + 1); // Render 1
setName("John"); // Render 2
setError(null); // Render 3
}, 1000);
// React 18+: Single batched render
setTimeout(() => {
setCount((c) => c + 1); // \
setName("John"); // } Single render
setError(null); // /
}, 1000);
This means we can be more liberal with state updates without performance concerns.
2. Transitions and Concurrent Rendering
useTransition lets us mark updates as non-urgent, preventing them from blocking user interactions:
function SearchResults() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (newQuery) => {
setQuery(newQuery); // Urgent: Update input immediately
startTransition(() => {
// Non-urgent: Can be interrupted
setResults(searchData(newQuery));
});
};
return (
<div>
<input value={query} onChange={(e) => handleSearch(e.target.value)} />
<div className={isPending ? "loading" : ""}>
{results.map((result) => (
<ResultItem key={result.id} {...result} />
))}
</div>
</div>
);
}
3. Suspense for Data Fetching
Suspense moves loading states out of component logic and into the component tree structure:
function UserProfile({ userId }) {
// No loading state needed!
const user = useSuspenseQuery(["user", userId], () => fetchUser(userId));
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
function App() {
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId="123" />
</Suspense>
);
}
The New Mental Model
These features enable a fundamentally different approach to state management:
1. Declarative Loading States
Instead of manually managing loading booleans, we declare loading boundaries:
// Old way: Manual loading management
function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchProducts().then((data) => {
setProducts(data);
setLoading(false);
});
}, []);
if (loading) return <Spinner />;
return <ProductGrid products={products} />;
}
// New way: Declarative boundaries
function ProductList() {
const products = useSuspenseQuery(["products"], fetchProducts);
return <ProductGrid products={products} />;
}
function App() {
return (
<Suspense fallback={<ProductListSkeleton />}>
<ProductList />
</Suspense>
);
}
2. Optimistic Updates with Confidence
Concurrent features make optimistic updates safer and more predictable:
function LikeButton({ postId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes);
const [isPending, startTransition] = useTransition();
const handleLike = () => {
// Immediate optimistic update
setLikes((prev) => prev + 1);
startTransition(async () => {
try {
const result = await likePost(postId);
setLikes(result.likes); // Confirm with server data
} catch (error) {
setLikes((prev) => prev - 1); // Rollback on error
showError("Failed to like post");
}
});
};
return (
<button onClick={handleLike} disabled={isPending}>
❤️ {likes}
</button>
);
}
3. Compositional Error Boundaries
Error handling becomes compositional rather than component-specific:
// Component focuses on happy path
function OrderSummary({ orderId }) {
const order = useSuspenseQuery(["order", orderId], () => fetchOrder(orderId));
return <OrderDetails order={order} />;
}
// Error handling at boundary level
function OrderPage({ orderId }) {
return (
<ErrorBoundary fallback={<OrderErrorFallback />}>
<Suspense fallback={<OrderSkeleton />}>
<OrderSummary orderId={orderId} />
</Suspense>
</ErrorBoundary>
);
}
Real-World Patterns
Here are practical patterns that leverage concurrent features:
Pattern 1: Progressive Enhancement
function Dashboard() {
return (
<div>
{/* Critical content loads immediately */}
<UserHeader />
{/* Secondary content can be deferred */}
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}
Pattern 2: Intelligent Preloading
function ProductCard({ product }) {
const [isPending, startTransition] = useTransition();
const handleHover = () => {
// Preload details in background
startTransition(() => {
queryClient.prefetchQuery(["product", product.id], () =>
fetchProductDetails(product.id)
);
});
};
return (
<div onMouseEnter={handleHover}>
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
</div>
);
}
Pattern 3: Smart State Coordination
function ShoppingCart() {
const [items, setItems] = useState([]);
const [isPending, startTransition] = useTransition();
const addItem = (product) => {
// Immediate UI update
setItems((prev) => [...prev, product]);
// Background sync
startTransition(() => {
syncCartToServer(items);
});
};
return (
<div className={isPending ? "syncing" : ""}>
{items.map((item) => (
<CartItem key={item.id} {...item} />
))}
</div>
);
}
Migration Strategy
You don’t need to rewrite everything at once. Here’s a gradual approach:
Phase 1: Adopt Automatic Batching
Simply upgrade to React 18. Your existing code gets better performance automatically.
Phase 2: Introduce Suspense Boundaries
Start wrapping async components with Suspense, beginning with non-critical sections.
Phase 3: Use Transitions for Heavy Updates
Identify expensive state updates and wrap them with startTransition.
Phase 4: Rethink State Architecture
Gradually move toward declarative patterns, removing manual loading and error states.
Common Pitfalls
1. Overusing Transitions
Not every state update needs useTransition. Use it for expensive updates that might block interactions.
2. Suspense Without Error Boundaries
Always pair Suspense with Error Boundaries for complete async state handling.
3. Fighting the New Patterns
Don’t force old patterns into concurrent features. Embrace the declarative approach.
The Future of State Management
Concurrent features point toward a future where:
- Components focus on logic, not loading states
- Error handling is compositional and reusable
- Performance optimizations happen automatically
- State updates are more predictable and resilient
Libraries like React Query, SWR, and Zustand are already adapting to these patterns. The state management landscape is evolving from imperative orchestration to declarative coordination.
Key Takeaways
React’s concurrent features aren’t just performance improvements — they’re a new foundation for state management:
- Think in boundaries rather than component-level states
- Embrace declarative patterns for loading and error handling
- Use transitions to prioritize user interactions
- Leverage automatic batching for simpler update logic
- Migrate gradually to avoid disrupting existing functionality
The shift from imperative to declarative state management is as significant as the move from class components to hooks. Developers who embrace these patterns early will build more resilient, performant applications with less complexity.
Start small, experiment with Suspense boundaries, and gradually adopt the new mental model. Your future self (and your users) will thank you.