Logo

September 10, 2025

Why React’s Concurrent Features Will Change How You Think About State Management

React's concurrent features enable declarative state management through Suspense, transitions, and automatic batching.

Written By

Ajay Gupta

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:

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:

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.