Skip to content

useTransition for Expensive Updates

advanced9 min read

Keep the UI Alive During Heavy Renders

Picture this: you have a tab panel, each tab renders a complex visualization with 1,000 data points. You click a tab and... nothing. The whole UI freezes for 300ms while React grinds through the new content. Your cursor won't blink. Your scroll is dead. The user thinks your app is broken.

useTransition fixes this. The tab indicator switches instantly, the old content stays visible (optionally dimmed), and the new content renders in the background.

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

  function handleTabClick(tab) {
    startTransition(() => {
      setActiveTab(tab);
    });
  }

  return (
    <>
      <TabBar active={activeTab} onChange={handleTabClick} />
      <div style={{ opacity: isPending ? 0.7 : 1, transition: 'opacity 150ms' }}>
        <TabContent tab={activeTab} />
      </div>
    </>
  );
}

The Mental Model

Mental Model

Think of useTransition as a video call with screen sharing. Without transitions, when you switch presentations, everyone stares at a frozen screen for 10 seconds while the new slide loads. With transitions, you say "switching now" (isPending = true), your camera stays on, people can still chat and interact, and the new presentation appears when it's ready.

The key: the current content stays visible while the new content prepares. Users never see a blank screen or frozen UI. They see a visual cue (dimming, spinner) that something is loading, and they can interact with the rest of the app normally.

When to Use useTransition

Not every state update needs a transition. Use it when ALL of these are true:

  1. A state update triggers an expensive render (many components, heavy computation)
  2. Showing stale content temporarily is acceptable (tab content, search results, filtered lists)
  3. User needs immediate feedback that their action was received (tab highlight, loading indicator)
// GOOD: Search results can show stale data while new results render
function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  function handleSearch(value) {
    setQuery(value);  // Sync: input updates immediately
    startTransition(() => {
      setResults(filterProducts(value));  // Transition: renders in background
    });
  }

  return (
    <>
      <input value={query} onChange={e => handleSearch(e.target.value)} />
      {isPending && <LoadingBar />}
      <ResultList items={results} />
    </>
  );
}

// BAD: Form submission result can't show stale data
function LoginForm() {
  const [status, setStatus] = useState('idle');
  const [isPending, startTransition] = useTransition();

  async function handleSubmit() {
    // Don't use transition here — the user needs to know
    // immediately whether login succeeded or failed
    setStatus('loading');
    const result = await login(credentials);
    setStatus(result.success ? 'success' : 'error');
  }
}
Execution Trace
Click tab
startTransition(() => setActiveTab('analytics'))
Update queued on TransitionLane
Sync render
isPending = true
React does immediate render to show pending state
User sees
Tab highlighted + old content dimmed
Immediate feedback. UI is responsive
Background
React renders AnalyticsTab
Heavy render runs in 5ms chunks, yielding between
Interrupt?
User clicks different tab
React discards analytics render, starts new transition
Complete
isPending = false
New tab content appears. Dimming removed

Practical Patterns

Pattern 1: Filtered List

function ProductCatalog({ products }) {
  const [category, setCategory] = useState('all');
  const [isPending, startTransition] = useTransition();

  const filtered = products.filter(p =>
    category === 'all' || p.category === category
  );

  return (
    <>
      <CategoryFilter
        active={category}
        onChange={(cat) => {
          startTransition(() => setCategory(cat));
        }}
      />
      <div className={isPending ? 'opacity-60' : ''}>
        {filtered.map(p => <ProductCard key={p.id} product={p} />)}
      </div>
    </>
  );
}

Pattern 2: Slow Navigation

function App() {
  const [page, setPage] = useState('/');
  const [isPending, startTransition] = useTransition();

  function navigate(path) {
    startTransition(() => setPage(path));
  }

  return (
    <>
      <Nav current={page} onNavigate={navigate} isPending={isPending} />
      <Suspense fallback={<PageSkeleton />}>
        <div className={isPending ? 'opacity-60 pointer-events-none' : ''}>
          <PageContent path={page} />
        </div>
      </Suspense>
    </>
  );
}

The navigation link highlights immediately. The old page content dims but stays visible. The new page renders in the background. User can click a different link to redirect.

Common Trap

startTransition doesn't move work to a Web Worker or a background thread. All rendering still happens on the main thread. The "background" rendering is achieved by time-slicing: React works for 5ms, yields to the browser, works for 5ms more. If a single component takes 100ms to render, that 100ms still blocks. useTransition helps with MANY components, not a single slow one. For a single slow computation, use useMemo or a Web Worker.

Production Scenario: The Map with 10,000 Markers

This is the kind of problem where useTransition really shines. A real estate app renders 10,000 property markers on a map. When the user drags the price filter slider, all markers need to update:

function PropertyMap() {
  const [priceRange, setPriceRange] = useState([0, 1000000]);
  const [isPending, startTransition] = useTransition();

  const markers = properties.filter(
    p => p.price >= priceRange[0] && p.price <= priceRange[1]
  );

  return (
    <>
      <PriceSlider
        value={priceRange}
        onChange={(range) => {
          startTransition(() => setPriceRange(range));
        }}
      />
      <div style={{ opacity: isPending ? 0.5 : 1 }}>
        <Map>
          {markers.map(m => <Marker key={m.id} property={m} />)}
        </Map>
      </div>
    </>
  );
}

Without transition: dragging the price slider freezes the map. Each slider position change blocks for 200ms. The slider feels stuck.

With transition: the slider moves fluidly (each slider change interrupts the previous transition). The map shows old markers dimmed while new markers render. The UI never freezes because React yields between marker renders.

Common Mistakes

What developers doWhat they should do
Wrapping the input's own state update in startTransition
Input state in a transition means the user's typing is delayed. The keystrokes need immediate feedback
Keep input state synchronous. Only transition the derived/expensive state update
Using transitions for data fetching delays
Transitions keep the old UI while rendering the new. If the slowness is a network request (not rendering), the transition doesn't help — the render is fast, the wait is for data
Transitions are for render-heavy updates. For fetch-related loading, use Suspense or loading state
Expecting isPending to be true synchronously after startTransition
isPending is React state. It updates when React commits the pending render, not when startTransition is called
isPending updates in the next render. Check it in JSX, not in the event handler
Using transitions for animations or visual effects
useTransition is about React rendering priority, not visual animations. The name is misleading — it's about state transitions, not visual transitions
Use CSS transitions, WAAPI, or requestAnimationFrame for visual animations

Challenge

Challenge: Add transitions to a dashboard

// This dashboard freezes for 500ms when switching
// between different time ranges. The date picker
// doesn't respond to subsequent clicks until the
// current render completes.

function AnalyticsDashboard() {
  const [timeRange, setTimeRange] = useState('7d');
  const data = useAnalyticsData(timeRange);

  return (
    <div>
      <DateRangePicker
        value=`{timeRange}`
        onChange=`{setTimeRange}`
      />
      <MetricsGrid data={data} />
      <ChartSection data={data} />
      <TableSection data={data} />
    </div>
  );
}

// Make this dashboard responsive during time range changes.
// Requirements:
// 1. Date picker responds immediately
// 2. Dashboard dims during transition
// 3. Rapid clicks discard in-progress renders
Show Answer
function AnalyticsDashboard() {
  const [timeRange, setTimeRange] = useState('7d');
  const [isPending, startTransition] = useTransition();
  const data = useAnalyticsData(timeRange);

  function handleRangeChange(range) {
    startTransition(() => {
      setTimeRange(range);
    });
  }

  return (
    <div>
      <DateRangePicker
        value={timeRange}
        onChange={handleRangeChange}
      />
      {isPending && <ProgressBar />}
      <div
        style={{
          opacity: isPending ? 0.6 : 1,
          transition: 'opacity 200ms',
          pointerEvents: isPending ? 'none' : 'auto',
        }}
      >
        <MetricsGrid data={data} />
        <ChartSection data={data} />
        <TableSection data={data} />
      </div>
    </div>
  );
}

How it works:

  1. Immediate response: The date picker updates its selected state immediately (the value prop reflects the new range because setTimeRange is called). The transition only affects the rendering of the dashboard content.
  2. Dim during transition: isPending controls opacity and adds a progress bar.
  3. Rapid clicks discard work: Each new startTransition call for a different time range interrupts the previous one. React discards the in-progress render and starts fresh with the latest range.

Quiz

Quiz
What does useTransition actually do at the React internals level?

Key Rules

Key Rules
  1. 1useTransition marks state updates as non-urgent. React renders them in the background, yielding every 5ms to keep the UI responsive.
  2. 2isPending signals that a transition is in progress. Use it for loading indicators and dimming stale content.
  3. 3Keep direct interaction state (input values, toggles) synchronous. Only wrap expensive derived renders in transitions.
  4. 4Each new transition call interrupts the previous one. Rapid actions (typing, clicking) discard in-progress renders automatically.
  5. 5Transitions show old content while new content renders. They don't show Suspense fallbacks — they avoid hiding already-visible content.
  6. 6useTransition doesn't make renders faster — it makes them non-blocking. Total render time may be slightly longer due to yielding overhead.