Skip to main content

Command Palette

Search for a command to run...

Concurrent Rendering in React: What It Actually Is, And When It's Just Hiding a Problem

useTransition, useDeferredValue, startTransition explained properly, plus the cases where concurrent rendering is the wrong fix entirely

Updated
17 min read
Concurrent Rendering in React: What It Actually Is, And When It's Just Hiding a Problem
S
Senior Software Engineer with a unique foundation in IT operations. I have spent the last 7+ years building scalable UI architectures in React and TypeScript. I focus on bridging the gap between business intent and robust code by defining cross-team API contracts, setting product roadmaps, and owning frontend architecture. My writing explores the intersection of product leadership, system design, and the realities of modern web development.

"Concurrent" is one of those words React throws around that sounds like it means something bigger than it does. People hear it and think multi-threading, parallel execution, workers doing real work off the main thread.

None of that is what's happening. React is still single-threaded JavaScript. Concurrent rendering doesn't make your code run in parallel. What it does is let React pause, interrupt, and resume rendering work, so it can prioritize urgent updates over less urgent ones.

That distinction matters because if you think concurrent rendering makes things faster, you'll reach for useTransition to fix a slow computation and be confused when it doesn't get any faster. It's not a speed tool. It's a scheduling tool.

This post is about understanding what's actually happening under the hood, how to use the three concurrent APIs correctly, and the situations where reaching for concurrent rendering is the wrong move because it hides a problem instead of solving it.


What "Concurrent" Actually Means

Before React 18, rendering was synchronous and blocking. Once React started rendering an update, it ran to completion. If that update involved expensive work, the browser couldn't do anything else, including responding to user input, until the render finished.

// Before concurrent rendering, this state update is one uninterruptible block
const handleChange = (e) => {
  setQuery(e.target.value);     // urgent: should update the input immediately
  setResults(filterBigList(e.target.value)); // expensive: blocks everything else
};

If filterBigList takes 200ms to run on a big array, the input feels frozen for 200ms. The browser can't paint the keystroke you just typed because React is busy computing results, and React doesn't yield control back to the browser until its render pass finishes.

Concurrent rendering changes this. React can start rendering an update, pause partway through if something more urgent comes in (like a new keystroke), handle the urgent thing first, and then resume or restart the original render later.

The mechanism that makes this possible is the Fiber architecture, which React has had since React 16. Fiber represents the component tree as a linked list of units of work instead of a single recursive call stack. Because each fiber node is a discrete unit, React can check in between units of work and decide "should I keep going or should I yield back to the browser right now."

Concurrent features in React 18+ are really just APIs that let you tell React "this update doesn't need to happen with top priority, you're allowed to interrupt it if something more important shows up."

That's the entire concept. Not parallelism. Interruptible, priority-based scheduling of a single-threaded renderer.


Urgent vs Non-Urgent Updates

This is the mental model the rest of the APIs build on.

Urgent updates are things the user expects to see immediately. Typing in an input, clicking a button, dragging a slider. If these lag, the UI feels broken.

Non-urgent updates are downstream consequences of urgent ones that can lag slightly without anyone noticing. Search results updating after you type, a filtered list re-rendering after you change a filter, a chart re-drawing after you adjust a parameter.

const handleSearch = (value) => {
  setQuery(value);          // urgent - the input must reflect this instantly
  setSearchResults(value);  // non-urgent - results can lag a beat
};

Concurrent rendering APIs exist to mark the second category explicitly, so React knows it's allowed to deprioritize that work.


startTransition

startTransition wraps a state update and tells React: this update can be interrupted, don't block urgent work for it.

import { startTransition, useState } from 'react';

const SearchPage = () => {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  const handleChange = (e) => {
    const value = e.target.value;

    setQuery(value); // urgent, runs immediately, input stays responsive

    startTransition(() => {
      // non-urgent, React can interrupt this if more input comes in
      setResults(filterLargeDataset(value));
    });
  };

  return (
    <div>
      <input value={query} onChange={handleChange} />
      <ResultsList results={results} />
    </div>
  );
};

What actually happens when you type fast: React starts the setResults transition for the first keystroke. Before that finishes, you type a second character. React abandons the in-progress transition render and starts again with the latest value. The input itself never lags because setQuery was never wrapped in the transition, it always runs at full priority.

This is the part most people get wrong: startTransition doesn't make filterLargeDataset run faster. It still takes exactly as long. What changes is that React can interrupt it and discard stale work instead of letting it block the main thread to completion.


useTransition

useTransition is startTransition plus a pending flag, so you can show a loading state while the transition is in flight.

import { useTransition, useState } from 'react';

const SearchPage = () => {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value);

    startTransition(() => {
      setResults(filterLargeDataset(value));
    });
  };

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending && <Spinner />}
      <ResultsList results={results} />
    </div>
  );
};

isPending is true while the transition is processing and flips back to false once it commits. This is the right way to show "results are catching up" without blocking the input itself.

A common pattern: dim the old results while new ones compute, instead of showing a spinner that replaces content.

const SearchPage = () => {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value);
    startTransition(() => {
      setResults(filterLargeDataset(value));
    });
  };

  return (
    <div>
      <input value={query} onChange={handleChange} />
      <div style={{ opacity: isPending ? 0.5 : 1, transition: 'opacity 0.2s' }}>
        <ResultsList results={results} />
      </div>
    </div>
  );
};

This gives the user a clear visual signal that something's updating, without the jarring flash of a loading spinner replacing real content on every keystroke.

Transitions With Navigation

useTransition is also useful for tab switches and route-like UI where the next view takes a moment to render.

const TabbedView = () => {
  const [activeTab, setActiveTab] = useState('overview');
  const [isPending, startTransition] = useTransition();

  const selectTab = (tab) => {
    startTransition(() => {
      setActiveTab(tab);
    });
  };

  return (
    <div>
      <TabBar active={activeTab} onSelect={selectTab} disabled={isPending} />
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        {activeTab === 'overview' && <Overview />}
        {activeTab === 'analytics' && <HeavyAnalyticsTab />}
        {activeTab === 'reports' && <ReportsTab />}
      </div>
    </div>
  );
};

If HeavyAnalyticsTab takes time to render, the old tab stays visible and interactive while the new one renders in the background, instead of the whole UI freezing on click.


useDeferredValue

useDeferredValue solves a similar problem from a different angle. Instead of wrapping the state setter, you wrap the value itself, and React gives you a deferred copy that lags behind during urgent updates.

import { useDeferredValue, useState } from 'react';

const SearchPage = () => {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {/* This re-renders with the deferred value, lagging behind input on purpose */}
      <SearchResults query={deferredQuery} />
    </div>
  );
};

The difference from useTransition: you don't control when the state update happens, you're deferring the consumption of a value rather than the act of setting it. This matters when you don't own the state setter, for example when the value comes from a prop or a parent component.

// You don't control how `filterText` is set - it's a prop
const ProductGrid = ({ filterText, products }) => {
  const deferredFilterText = useDeferredValue(filterText);

  // Expensive filtering operation uses the deferred value
  const filtered = useMemo(() => {
    return products.filter((p) =>
      p.name.toLowerCase().includes(deferredFilterText.toLowerCase())
    );
  }, [products, deferredFilterText]);

  return <Grid items={filtered} />;
};

ProductGrid has no access to the setter for filterText. useTransition requires wrapping the setter, which you can't do here. useDeferredValue solves this by deferring on the receiving end instead.

Detecting Staleness

You can compare the deferred value to the live value to know if you're showing stale content, similar to isPending.

const ProductGrid = ({ filterText, products }) => {
  const deferredFilterText = useDeferredValue(filterText);
  const isStale = filterText !== deferredFilterText;

  const filtered = useMemo(() => {
    return products.filter((p) =>
      p.name.toLowerCase().includes(deferredFilterText.toLowerCase())
    );
  }, [products, deferredFilterText]);

  return (
    <div style={{ opacity: isStale ? 0.6 : 1 }}>
      <Grid items={filtered} />
    </div>
  );
};

useTransition vs useDeferredValue: Which One

Do you control the state setter directly?
  Yes → useTransition (wrap the setState call)
  No, it's a prop or external value → useDeferredValue (wrap the value)

Need a clean pending boolean for UI feedback?
  useTransition gives you isPending directly
  useDeferredValue requires a manual comparison (value !== deferredValue)

Triggering a route or tab change?
  useTransition fits better, it's action-oriented

Receiving a value that flows from elsewhere and is expensive to consume?
  useDeferredValue fits better, it's value-oriented

In practice, a lot of real apps use both: useTransition at the point where state is set, useDeferredValue further down the tree where a derived value is expensive to compute.


Concurrent Rendering and Suspense

Concurrent rendering is also what makes Suspense for data fetching feel smooth instead of jarring. Without concurrent features, every Suspense boundary that triggers would have to fully block until resolved.

import { Suspense } from 'react';

const ProductPage = ({ productId }) => {
  return (
    <Suspense fallback={<ProductSkeleton />}>
      <ProductDetails productId={productId} />
    </Suspense>
  );
};

If you change productId and ProductDetails suspends again while fetching the new product, concurrent rendering lets React keep the previous product's UI on screen instead of immediately tearing it down to the fallback. This pairs directly with useTransition:

const ProductPage = () => {
  const [productId, setProductId] = useState(1);
  const [isPending, startTransition] = useTransition();

  const selectProduct = (id) => {
    startTransition(() => {
      setProductId(id);
    });
  };

  return (
    <div style={{ opacity: isPending ? 0.6 : 1 }}>
      <ProductPicker onSelect={selectProduct} />
      <Suspense fallback={<ProductSkeleton />}>
        <ProductDetails productId={productId} />
      </Suspense>
    </div>
  );
};

Wrapping the setProductId call in a transition means React keeps showing the previous product, dimmed, instead of flashing the skeleton fallback every time you click a new one. Without the transition, every product switch would unmount the current view and show the loading skeleton, even if the fetch only takes 100ms.


When It Genuinely Helps

Search-as-you-type with expensive filtering or rendering

The textbook case. Filtering a large dataset, re-rendering a big list, recalculating derived values from a search input. The input itself stays responsive while the expensive part lags slightly.

Tab and view switches with heavy content

When switching to a tab or view triggers a render that takes noticeable time, transitions keep the old view interactive and visible instead of freezing the UI mid-click.

Data-fetching UIs with Suspense

Concurrent rendering is what makes Suspense boundaries swap gracefully rather than abruptly, especially when navigating between similar views (product pages, user profiles, detail panels) where you want the old content to stay until the new content is ready.

Any UI where input responsiveness and update latency have genuinely different priorities

If you can articulate "this part of the UI needs to feel instant, this other part can lag half a second," that's exactly the shape of problem concurrent rendering exists for.


When It's Just Hiding a Real Problem

This is the part most posts about concurrent rendering skip, and its the most important part if you actually want to write good code instead of papering over bad code.

The underlying computation is just badly written

If filterLargeDataset takes 800ms because it's doing something inefficient (nested loops, redundant re-computation, no memoization on intermediate steps), wrapping it in startTransition doesn't make it fast. It makes the slowness less visible by deferring it. The user still waits 800ms for results, they just don't notice the input lagging while they wait.

// The real problem: O(n*m) nested loop filtering
const filterLargeDataset = (query, items) => {
  return items.filter((item) => {
    return item.tags.some((tag) => tag.toLowerCase().includes(query.toLowerCase()));
    // If items has 50,000 entries and each has 20 tags, this is 1,000,000 comparisons
  });
};

Wrapping this in useTransition hides the lag from the input but the actual filtering is still slow. The fix here is a better algorithm, indexing, a search library (Fuse.js, or a backend search), or moving the work off the main thread with a Web Worker. Concurrent rendering doesn't reduce computation, it just reschedules when that computation blocks the UI.

You actually need pagination or virtualization, not deferral

If you're rendering 10,000 DOM nodes and wrapping the render in startTransition to make it feel less janky, you're treating the symptom. The DOM problem is still there: it's just spread out so it doesn't all happen synchronously. Virtualizing the list (see the previous post in this series) actually reduces the work instead of rescheduling it.

// This doesn't fix anything, it just defers when the 10,000-node render happens
startTransition(() => {
  setVisibleItems(allTenThousandItems);
});

// This actually fixes it - render 30 nodes instead of 10,000
const virtualizer = useVirtualizer({
  count: allTenThousandItems.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 50,
});

Backend or network latency is the actual bottleneck

If your "slow update" is really a 2-second API call, no client-side scheduling trick changes that. useTransition can make the waiting period feel smoother with isPending UI, but it does nothing to make the request faster. If users are waiting too long, the fix is backend optimization, caching, pagination of the request itself, or showing meaningful partial data, not client-side scheduling.

Using transitions to mask unnecessary re-renders

Sometimes the real issue is that a component re-renders far more than it needs to, because of unstable references, missing memoization, or state that lives higher in the tree than it should. Wrapping the resulting slow update in startTransition makes the janky re-render less noticeable rather than fixing why it's happening in the first place.

// The actual bug: this object is recreated every render, breaking memoization
// everywhere downstream, causing a cascade of unnecessary re-renders
const config = { theme, locale, permissions }; // new reference every time

// Wrapping the resulting update in a transition treats the symptom
startTransition(() => {
  setConfig(config);
});

// The real fix: stabilize the reference
const config = useMemo(() => ({ theme, locale, permissions }), [theme, locale, permissions]);

Throwing transitions at every state update out of habit

Once people learn startTransition exists, it's tempting to wrap every setState call in one "just in case." This is the same mistake as memoizing everything: each transition has scheduling overhead, and wrapping urgent updates that the user expects to see immediately (button clicks that should show feedback right away, form validation errors, toggle states) makes the UI feel less responsive, not more.

// Don't do this - the user needs to see this immediately
const handleToggle = () => {
  startTransition(() => {
    setIsOpen((prev) => !prev); // this should be instant, not deferred
  });
};

// Just do this
const handleToggle = () => {
  setIsOpen((prev) => !prev);
};

If the update is cheap and the user expects instant feedback, a transition adds nothing but a layer of indirection.


A Decision Framework

Is there an actual, measured responsiveness problem?
  No  → Don't add concurrent APIs. Ship the simple version.
  Yes → Profile it. What's actually slow?

         Is the computation itself inefficient?
           Yes → Fix the algorithm, memoize, move to a Web Worker,
                 or push to the backend. Transitions don't help here.

         Is it rendering too many DOM nodes?
           Yes → Virtualize. Transitions don't reduce DOM size.

         Is it network/API latency?
           Yes → Optimize the backend, cache, or show partial data.
                 Transitions can improve perceived wait but not actual wait.

         Is the slow part genuinely a low-priority update that's
         blocking a high-priority one (typing, clicking, dragging)?
           Yes → This is the actual use case for useTransition /
                 useDeferredValue. Use it here.

The pattern across all of this: concurrent rendering APIs are for cases where the work is necessary and appropriately sized, but the scheduling priority is wrong. If the work itself is the problem, fix the work.


Common Mistakes

Wrapping the wrong state update

The state that needs to feel instant (the input value, the toggle, the thing the user is directly interacting with) should never go inside startTransition. Only the downstream, derived, expensive update belongs there.

// Wrong - the input itself now lags
startTransition(() => {
  setQuery(value);
  setResults(filterLargeDataset(value));
});

// Right - input stays instant, only the expensive derived update is deferred
setQuery(value);
startTransition(() => {
  setResults(filterLargeDataset(value));
});

Expecting isPending to reflect network requests

isPending from useTransition reflects React's rendering work, not promises or async operations. If your transition triggers a fetch, isPending goes back to false as soon as the synchronous render commits, not when the fetch resolves.

// isPending does NOT track this fetch
startTransition(() => {
  fetchData().then(setData); // async work outside what isPending tracks
});

For tracking actual async fetch state, you want Suspense with a data-fetching library that integrates with it (React Query, Relay, or framework-level data fetching), or your own loading state managed separately.

Assuming startTransition makes synchronous code non-blocking

startTransition does not move work off the main thread. It's still synchronous JavaScript running on the same thread as everything else. What it does is let React interrupt and restart that work between yield points. If your transition wraps a single giant synchronous function with no internal yield points, React still can't interrupt mid-function, it can only interrupt between renders.

For genuinely heavy computation that needs to not block at all, a Web Worker is the actual tool, not a transition.

// A transition can be interrupted between renders, but not mid-execution
// of a single long synchronous call
startTransition(() => {
  setResult(massiveSynchronousComputation(data)); // this still blocks while it runs
});

// For real non-blocking heavy computation, offload to a worker
const worker = new Worker('./heavy-computation-worker.js');
worker.postMessage(data);
worker.onmessage = (e) => setResult(e.data);

Conclusion

Concurrent rendering is a scheduling improvement, not a performance improvement. It doesn't make slow code fast, it makes React smarter about which updates get to interrupt which other updates. That's a genuinely useful capability when you have a clear urgent/non-urgent split in your UI, like a search input feeding an expensive results list.

Where it goes wrong is when its used to paper over real problems: bad algorithms, unvirtualized large lists, slow networks, unstable references causing cascading re-renders. In all of those cases, the work itself needs to change. Deferring when slow work happens doesn't make it not slow, it just moves the moment you notice it.

Use useTransition when you control the state update and want a pending flag. Use useDeferredValue when you're consuming a value you don't control the setter for. Use neither if the actual fix is a better algorithm, virtualization, or a faster backend.

The question to ask before reaching for any of this is the same one that's come up in every post in this series: have you measured the actual bottleneck, or are you guessing? Concurrent rendering solves a specific, well-defined problem. It's not a general purpose "make my app faster" button.