Skip to content

React Concurrent Features Explained — useTransition, useDeferredValue, and Suspense

DodaTech 8 min read

In this tutorial, you'll learn about React Concurrent Features Explained. We cover key concepts, practical examples, and best practices.

React Concurrent Features let you mark some state updates as non-urgent so the browser stays responsive to user input while heavy rendering happens in the background.

What You'll Learn

Build responsive React UIs using concurrent features — useTransition for non-blocking state updates, useDeferredValue for deferring derived values, and useSyncExternalStore for concurrent-safe external subscriptions.

Why It Matters

Without concurrency, every state update blocks the main thread until rendering finishes. A heavy list filter that takes 50ms causes visible input lag. Concurrent features interrupt long renders to process input first, then resume rendering. This cuts perceived latency by 60-80% in data-heavy UIs.

Real-World Use

A searchable product catalog with 10,000 items filters the list as the user types. Without concurrency, each keystroke lags as the list re-renders. With useTransition, keystrokes are processed instantly and the filtered list renders in the background. Doda Browser's extension manager uses this — typing in the search box stays smooth while filtering hundreds of extensions.

useTransition — Non-Blocking State Updates

Wrap non-urgent state updates in startTransition to let React interrupt them for more urgent updates:

import { useState, useTransition } from "react";

const items = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`);

export default function SearchPage() {
  const [query, setQuery] = useState("");
  const [filtered, setFiltered] = useState(items);
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setQuery(value); // urgent: update the input value immediately

    startTransition(() => {
      // non-urgent: filter the list — React can interrupt this
      setFiltered(
        items.filter(item =>
          item.toLowerCase().includes(value.toLowerCase())
        )
      );
    });
  }

  return (
    <div style={{ maxWidth: 600, margin: "2rem auto" }}>
      <input
        value={query}
        onChange={handleChange}
        placeholder="Search 10,000 items..."
        style={{ width: "100%", padding: 12, fontSize: 16, border: "1px solid #ccc", borderRadius: 4 }}
      />
      {isPending && <p style={{ color: "#888" }}>Updating list...</p>}
      <ul style={{ maxHeight: 400, overflow: "auto", marginTop: 12 }}>
        {filtered.map(item => (
          <li key={item} style={{ padding: "4px 0" }}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

Expected output: Typing in the input is always responsive — no lag. The filtered list updates asynchronously. React interrupts the list filtering each time a new keystroke arrives, ensuring the input never freezes. isPending shows a visual indicator when the list is stale.

useDeferredValue — Deferring Derived Values

useDeferredValue is a simpler alternative to useTransition when you cannot control where the state is set:

import { useState, useDeferredValue, useMemo } from "react";

const allPosts = Array.from({ length: 5000 }, (_, i) => ({
  id: i,
  title: `Post ${i + 1}`,
  content: `Content of post ${i + 1}`,
}));

export default function BlogSearch() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  const filteredPosts = useMemo(
    () => allPosts.filter(post =>
      post.title.toLowerCase().includes(deferredQuery.toLowerCase())
    ),
    [deferredQuery]
  );

  return (
    <div style={{ maxWidth: 600, margin: "2rem auto" }}>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search posts..."
        style={{ width: "100%", padding: 12, fontSize: 16, border: "1px solid #ccc", borderRadius: 4 }}
      />
      <div style={{ opacity: isStale ? 0.5 : 1, transition: "opacity 0.2s", marginTop: 12 }}>
        {filteredPosts.map(post => (
          <article key={post.id} style={{ padding: "8px 0", borderBottom: "1px solid #eee" }}>
            <h3 style={{ margin: 0 }}>{post.title}</h3>
            <p style={{ margin: "4px 0 0", color: "#555" }}>{post.content}</p>
          </article>
        ))}
      </div>
    </div>
  );
}

Expected output: deferredQuery lags behind query by one render cycle. The input stays responsive while the filtered list renders with the deferred (older) value. isStale dims the old list visually, providing a smooth transition instead of a blank loading state.

useSyncExternalStore — Concurrent-Safe External Stores

When using external state (Redux, Zustand, global stores), useSyncExternalStore ensures correct behavior during concurrent rendering:

import { useSyncExternalStore } from "react";

// External store (could be Zustand, Redux, or a plain object)
let counter = 0;
const listeners = new Set<() => void>();

const store = {
  getSnapshot: () => counter,
  subscribe: (callback: () => void) => {
    listeners.add(callback);
    return () => listeners.delete(callback);
  },
};

export function increment() {
  counter++;
  listeners.forEach(l => l());
}

export function useCounter() {
  return useSyncExternalStore(store.subscribe, store.getSnapshot);
}

// Usage in a component
function CounterDisplay() {
  const count = useCounter();
  return (
    <div style={{ textAlign: "center", padding: "2rem" }}>
      <h2>Counter: {count}</h2>
      <button
        onClick={increment}
        style={{ padding: "8px 16px", fontSize: 18, cursor: "pointer" }}
      >
        Increment
      </button>
    </div>
  );
}

Expected output: useSyncExternalStore subscribes to the external store and returns the current snapshot. During concurrent rendering, React uses getSnapshot to ensure it reads a consistent value. If the store changes during a render, React detects the tear and re-renders synchronously. Without this, concurrent rendering could show inconsistent state.

Concurrent Rendering with Suspense

Combine concurrent features with Suspense for interruption-safe data loading:

import { Suspense, useTransition } from "react";
import { createResource } from "./data";

const resource = createResource();

function SlowProfile() {
  const data = resource.read();
  return (
    <div style={{ padding: "1rem", border: "1px solid #ddd", borderRadius: 8 }}>
      <h3>{data.name}</h3>
      <p>{data.bio}</p>
    </div>
  );
}

export default function ProfilePage() {
  const [isPending, startTransition] = useTransition();

  function refresh() {
    startTransition(() => {
      resource.refetch();
    });
  }

  return (
    <div>
      <button
        onClick={refresh}
        disabled={isPending}
        style={{
          padding: "8px 16px",
          background: isPending ? "#ccc" : "#1976d2",
          color: "#fff",
          border: "none",
          borderRadius: 4,
          cursor: isPending ? "not-allowed" : "pointer",
          marginBottom: 16,
        }}
      >
        {isPending ? "Refreshing..." : "Refresh Profile"}
      </button>
      <Suspense fallback={<div>Loading profile...</div>}>
        <SlowProfile />
      </Suspense>
    </div>
  );
}

Expected output: Clicking "Refresh Profile" triggers startTransition, which tells React the Suspense fallback reveal is low priority. If the data loads fast enough (within a timeout), React shows the new data directly without flashing the fallback. If loading is slow, the fallback appears but user interactions remain responsive.

Common Errors

Error: 'startTransition' cannot be called outside a transition

You called the startTransition function returned by useTransition outside a React event handler. startTransition must be called synchronously during an event (onClick, onChange, etc.). Calling it in a timeout or effect will log a warning — use useDeferredValue instead for those cases.

Error: 'useSyncExternalStore' received a getSnapshot that returned different values before and after subscription

The getSnapshot function must return a stable, immutable snapshot that does not change between calls within the same render. If your store mutates the same object, return a copy: getSnapshot: () => ({...store.data}).

Error: A component suspended while responding to synchronous input

You triggered a Suspense fallback during onClick or onChange. Wrap the state update that triggers the suspension in startTransition. This lets React keep showing the current UI instead of immediately revealing the fallback.

Warning: 'useDeferredValue' will not defer the initial render

useDeferredValue only defers updates after the first render. The initial value passes through immediately. This is intentional — there is no previous value to show on first mount. The deferred effect kicks in on subsequent changes.

Error: Cannot read properties of null (reading 'read')

Your Suspense resource was accessed before it was initialized. Ensure createResource() is called outside the component or inside a useEffect/useMemo. Calling it in the render body creates a new resource on every render

Practice Questions

  1. What is the difference between useTransition and useDeferredValue? useTransition wraps state updates, letting you control which updates are urgent vs non-urgent from the setter side. useDeferredValue works on the consumer side — it defers a value derived from urgent state, useful when you cannot control where the state is set.

  2. How does useSyncExternalStore prevent "tearing" during concurrent rendering? Tearing occurs when components see different versions of external state during the same render. useSyncExternalStore calls getSnapshot to capture a consistent value. If the store changes mid-render, React detects the mismatch and re-renders synchronously.

  3. When does isPending from useTransition return true? When a transition started but has not yet completed. This happens when the state update triggers a Suspense boundary (data fetching) or when the deferred render is still in progress. It returns false once the new UI has committed.

Challenge

Build a live search UI that queries 20,000 records. Use useTransition for input responsiveness and a simulated network delay. Show the stale result set while the new results are loading. Add a pending indicator that appears only if the transition takes longer than 200ms. Compare input responsiveness with and without concurrency.

Real-World Task

Find a component in your app that feels sluggish during input (search, filter, or sort). Profile it with React DevTools. Wrap the filtering state update in startTransition. If the data comes from props or context, use useDeferredValue instead. Measure the input response time before and after using the Performance tab in DevTools.

Build a product catalog with:

  • 10,000 product items loaded from a simulated API
  • Real-time search with useTransition for non-blocking filtering
  • Image thumbnails that lazy-load using Suspense
  • A "sort by" dropdown that triggers with useDeferredValue
  • Concurrent-safe external store for cart state using useSyncExternalStore

Profile the input latency and compare with and without concurrent features.

Learning Path

flowchart LR
  A[React Suspense & Streaming SSR] --> B[React Compiler Explained]
  B --> C[React Concurrent Features]
  C --> D[React Server Actions Guide]
  D --> E[React Query & TanStack Query]
  style C fill:#4a90d9,color:#fff

Next Steps

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Doda Browser's extension manager uses useTransition to keep the search input responsive while filtering hundreds of installed extensions — keystrokes never lag, even when the extension list has complex metadata to filter through.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro