EmpireUI
Get Pro
← Blog8 min read#useTransition#react#concurrent

useTransition in React: Non-Blocking Updates and Concurrent Rendering

useTransition lets React defer low-priority state updates so your UI stays responsive. Here's exactly how it works and when you'd actually reach for it.

developer writing React concurrent rendering code on dark monitor

What useTransition Actually Does

useTransition is a React 18 hook that lets you mark a state update as non-urgent. That's the whole idea. React defers those marked updates — the ones you wrap in startTransition — so they don't block higher-priority work like keystrokes, clicks, or anything the user is doing *right now*.

Before React 18 and the Concurrent Renderer (shipped stable in March 2022), every setState call was treated identically. React would synchronously re-render the entire affected subtree before painting the next frame. Filter a 10,000-item list on input change? The input would stutter. Tab switch triggers a heavy computation? The click feels laggy. There was no built-in way to tell React "this update can wait a beat."

useTransition changes that. It returns a tuple: [isPending, startTransition]. You wrap your expensive state setter in startTransition, React schedules it at a lower priority, and isPending flips to true while it's still working. You can use that boolean to show a spinner, fade the stale content, or do literally nothing — your call.

Worth noting: this isn't setTimeout trickery or debouncing. It's real scheduler-level prioritization inside React's concurrent runtime. The update *will* happen, just after urgent work finishes. If you're on React 17 or earlier, none of this applies to you — you'd need to upgrade first.

The Basic API and How to Use It

The hook signature is dead simple. No configuration object, no options, no library to install. Just this:

import { useTransition, useState } from 'react';

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

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

    // Deferred: filtering can wait
    startTransition(() => {
      setResults(filterHugeList(e.target.value));
    });
  }

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending && <span>Updating...</span>}
      <ResultList items={results} />
    </div>
  );
}

The two setState calls in handleChange are now on different priority tracks. setQuery fires immediately, keeping the input responsive at 16ms per frame. setResults inside startTransition is deprioritized — React will process it when the main thread isn't busy. On a fast machine the difference is invisible. On a mid-range Android phone in 2024 filtering a list of 50,000 strings, it's the difference between a smooth UI and a frozen one.

One constraint worth knowing: the function you pass to startTransition must be synchronous. You can't await inside it. If you need to kick off async work and *then* do a low-priority render, look at useDeferredValue instead — it's better suited for deferring values derived from async data.

Honestly, most tutorials stop at the basic example and never mention this, but startTransition is also available as a standalone import from React if you need it outside a component: import { startTransition } from 'react'. Useful in event handlers or utility functions where calling a hook directly isn't an option.

isPending: More Than a Loading Spinner

Most devs use isPending to slap a spinner on the screen and call it a day. That works, but it's underselling what you can do with it. Since isPending is just a boolean, you can use it to style the *stale* UI while the new one is being prepared — a subtle opacity drop, a blur, a shimmer. You're not replacing the content with a loader; you're signaling "this is stale" without a jarring layout shift.

<div
  style={{
    opacity: isPending ? 0.5 : 1,
    transition: 'opacity 200ms ease',
    filter: isPending ? 'blur(1px)' : 'none',
  }}
>
  <ResultList items={results} />
</div>

That 200ms transition matters. Without it, the opacity snaps on and off for fast transitions and looks glitchy. The combination of isPending fading + instant input response is what makes the UX feel polished. Quick aside: if you're building heavily styled components — glassmorphism panels, aurora backgrounds, layered cards — this kind of transition pairs especially well with Empire UI's aurora or glassmorphism components, since those already use CSS transitions under the hood.

One pattern I'd recommend: don't show the isPending indicator at all unless the transition has been pending for more than ~300ms. You can do this with a simple useEffect + setTimeout to set a local showSpinner state. Short transitions feel instant; only the truly slow ones need user feedback.

useTransition vs useDeferredValue: When to Use Which

These two hooks solve related but different problems and the React docs don't do a great job separating them. Here's the practical breakdown.

useTransition is for when *you control the state setter*. You own the setter, you wrap it in startTransition, done. It's explicit — you're saying "this specific update is low priority."

useDeferredValue is for when *you receive a value as a prop* and you want to defer it. Maybe a parent passes searchQuery down and you don't have access to the setter. You call const deferredQuery = useDeferredValue(searchQuery) and use deferredQuery for the expensive derived computation. React will keep deferredQuery at the previous value until it has time to update it.

// useDeferredValue pattern — when you don't own the setter
import { useDeferredValue, memo } from 'react';

const HeavyList = memo(({ query }: { query: string }) => {
  const deferredQuery = useDeferredValue(query);
  // expensive filtering happens here with deferredQuery
  const items = filterHugeList(deferredQuery);
  return <ul>{items.map(i => <li key={i}>{i}</li>)}</ul>;
});

In practice, useTransition is more predictable because you're explicitly in control of when transitions start and stop. useDeferredValue is more implicit — React decides when to "catch up." If you own the code end-to-end, reach for useTransition first.

Real-World Patterns: Tabs, Search, and Route Changes

The three most common places you'd actually reach for useTransition in production are: tab switching, live search filtering, and page/route transitions in a framework like Next.js. Let's look at each briefly.

Tab switching is the textbook case. Each tab renders a different heavy subtree. Without startTransition, clicking a tab that takes 80ms to render will freeze the entire page for 80ms — including the tab highlight animation. Wrap the active tab state update and the tab indicator updates instantly, with the heavy content catching up.

function TabbedLayout({ tabs }: { tabs: Tab[] }) {
  const [active, setActive] = useState(0);
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <nav>
        {tabs.map((tab, i) => (
          <button
            key={tab.id}
            // highlight updates instantly (outside transition)
            className={active === i ? 'active' : ''}
            onClick={() => {
              startTransition(() => setActive(i));
            }}
          >
            {tab.label}
          </button>
        ))}
      </nav>
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        {tabs[active].content}
      </div>
    </>
  );
}

Live search is exactly the use case from the earlier example. The input value updates synchronously; the filtered result list is wrapped in a transition. This pattern is so common that if you're building a search UI for something like a design system browser — say, browsing Empire UI components — you'd almost certainly want this pattern to keep the search box buttery while the result grid re-renders.

Route transitions in Next.js 14+ work a bit differently since navigation is handled by the router, not a local state setter. You can still use startTransition by wrapping router.push() calls, which defers the rendering of the incoming page. The App Router actually does some of this internally when you use <Link> with prefetching, but for programmatic navigation it's a useful pattern.

Performance Gotchas and What useTransition Won't Fix

Look, useTransition is not a magic performance fix. It makes the UI *feel* faster by prioritizing responsiveness, but the actual computation still has to happen. If filtering your list takes 500ms of CPU time, wrapping it in a transition means the input *feels* responsive, but after 500ms the results still render. You haven't done any less work.

The actual fix for expensive computation is still memoization (useMemo, memo), virtualization (only rendering visible rows), or moving work off the main thread to a Web Worker. useTransition is about *scheduling* priority, not reducing work. Don't use it as an excuse to skip optimization.

One more thing — startTransition won't help with work that happens *outside* React's scheduler. If you have a synchronous for loop that blocks the main thread for 200ms inside your state update, React can't interrupt it mid-way. Concurrent React can only interleave work at React's own yield points. Truly blocking JavaScript code needs to be broken up with setTimeout, Web Workers, or the upcoming scheduler.postTask browser API.

Worth noting: if you're using React DevTools Profiler (available since React 18.2), transitions are highlighted separately in the timeline. You can see exactly how long each transition takes and which components are causing the delay. That's the right debugging tool here, not console.log timers.

Finally, transitions affect renders inside the subtree, but they don't batch across entirely separate roots. If you're using ReactDOM.createRoot for portals or modals, those are separate roots and startTransition from one root won't defer work in another. Keep this in mind if you're building complex overlay systems or draggable panels.

Putting It Together: A Polished Search UI

Here's a fuller example combining useTransition, isPending styling, and debounce-free architecture. This is closer to what you'd ship than the toy examples:

import { useState, useTransition, memo } from 'react';

const ITEMS = Array.from({ length: 20_000 }, (_, i) => `Component-${i}`);

const ResultList = memo(({ items }: { items: string[] }) => (
  <ul className="grid grid-cols-3 gap-2">
    {items.slice(0, 100).map(item => (
      <li key={item} className="rounded-lg bg-white/10 px-3 py-2 text-sm">
        {item}
      </li>
    ))}
  </ul>
));

export function ComponentSearch() {
  const [query, setQuery] = useState('');
  const [filtered, setFiltered] = useState(ITEMS);
  const [isPending, startTransition] = useTransition();

  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setQuery(value); // urgent — instant

    startTransition(() => {
      const lower = value.toLowerCase();
      setFiltered(
        lower ? ITEMS.filter(s => s.toLowerCase().includes(lower)) : ITEMS
      );
    });
  }

  return (
    <div className="space-y-4">
      <input
        type="search"
        value={query}
        onChange={handleSearch}
        placeholder="Search components..."
        className="w-full rounded-xl border border-white/20 bg-white/5 px-4 py-3 outline-none"
      />
      <div
        style={{ opacity: isPending ? 0.5 : 1, transition: 'opacity 150ms' }}
      >
        <p className="mb-2 text-xs text-white/50">
          {filtered.length.toLocaleString()} results
        </p>
        <ResultList items={filtered} />
      </div>
    </div>
  );
}

Notice the memo wrapper on ResultList. Without it, even a transition-deferred render would re-render ResultList whenever the parent renders for any reason. memo + useTransition together give you both correct priority scheduling and minimal re-render work.

If you're building this kind of search UI inside a glassmorphism-styled panel or on top of an aurora background, the opacity fade on isPending looks especially good — the blurred background shifts slightly as new results load in, which feels intentional rather than buggy. You can pull those background components straight from Empire UI without any configuration.

The final UX result: the input stays snappy at every keystroke, results fade slightly while updating, and nothing blocks. No debouncing, no setTimeout, no Web Workers. Just React's scheduler doing its job.

FAQ

Can I use useTransition with async functions?

No — startTransition only accepts synchronous functions. For async work, kick off your fetch outside the transition, then wrap the resulting state update in startTransition when the data arrives.

Does useTransition work in React 17?

No. useTransition and the concurrent renderer shipped in React 18. You'd need to upgrade and switch to ReactDOM.createRoot for concurrent features to activate.

What's the difference between useTransition and debouncing?

Debouncing delays the update by a fixed time regardless of system load. useTransition lets React actually schedule work intelligently — if the browser is free, it renders immediately; if it's busy, it waits. No artificial delay.

Will useTransition help with slow initial page loads?

Not directly. It only affects client-side state updates post-mount. For initial load performance, look at server components, code splitting, and proper suspense boundaries instead.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React Concurrent Rendering: useTransition, useDeferredValue ExplainedReact Architecture & Patterns: The Complete 2026 GuideVirtual List in React: Rendering 100,000 Rows Without Breaking the BrowserLottie Animations in React: Setup, Optimisation and Pitfalls