ReactuseTransition Hook

useTransition Hook

React 18 introduced concurrent rendering — the ability to interrupt, pause, and resume rendering work. useTransition is the primary hook that lets your components opt into this power by telling React which state updates are non-urgent and can be interrupted by more important work like user input.

The Problem: Slow Updates Block the UI

Imagine a search input that filters a list of 10 000 items. Every keystroke triggers a state update, React re-renders the huge list, and the input itself freezes until rendering finishes. The user sees stuttering and lag — a broken experience.

JSX
// ✗ PROBLEMATIC — every keystroke re-renders the full list synchronously
function SlowSearch() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState(allItems)

  function handleChange(e) {
    setQuery(e.target.value)          // urgent: must update input immediately
    setResults(filterItems(e.target.value)) // slow: filters 10 000 items
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      <ItemList items={results} />    {/* expensive render */}
    </>
  )
}

Both state updates run synchronously in the same batch. React cannot commit the input update without also finishing the expensive list render — so the input lags.

useTransition: Marking Updates as Non-Urgent

useTransition returns a tuple of two values:

  • isPending — a boolean that is true while the transition is in progress

  • startTransition — a function that wraps the non-urgent state update

JSX
import { useState, useTransition } from 'react'

function SmootherSearch() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState(allItems)
  const [isPending, startTransition] = useTransition()

  function handleChange(e) {
    const value = e.target.value
    setQuery(value)                     // urgent — runs immediately

    startTransition(() => {
      setResults(filterItems(value))    // non-urgent — can be interrupted
    })
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <p>Updating results…</p>}
      <ItemList items={results} style={{ opacity: isPending ? 0.6 : 1 }} />
    </>
  )
}

Now setQuery runs first and React commits the input update immediately. The expensive setResults is deferred. If the user types another character before setResults finishes, React abandons the in-progress render and starts fresh with the latest query — no stale results, no frozen UI.

Note
startTransition must be called synchronously — you cannot await something and then call it. If you need to wrap an async operation, trigger the transition first and await inside the setter function's update logic.
Showing a Loading Indicator with isPending

The isPending flag is true for the entire duration of the transition — from when startTransition is called until React finishes rendering the deferred update. Use it to give users visual feedback:

JSX
function TabContainer() {
  const [activeTab, setActiveTab] = useState('home')
  const [isPending, startTransition] = useTransition()

  function selectTab(tab) {
    startTransition(() => {
      setActiveTab(tab)
    })
  }

  return (
    <div>
      <nav>
        {['home', 'about', 'posts'].map(tab => (
          <button
            key={tab}
            onClick={() => selectTab(tab)}
            style={{
              fontWeight: activeTab === tab ? 'bold' : 'normal',
              opacity: isPending ? 0.7 : 1,
            }}
          >
            {tab}
          </button>
        ))}
      </nav>

      {isPending ? (
        <div className="spinner" aria-label="Loading…" />
      ) : (
        <TabContent tab={activeTab} />
      )}
    </div>
  )
}

Notice that the active tab button updates immediately on click (because activeTab itself is set inside the transition and React keeps the old UI visible while preparing the new one). The spinner only appears while the heavy TabContent is rendering.

A Complete Slow-List Demo

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

// Artificially slow item — each renders 1ms of blocking work
const SlowItem = memo(function SlowItem({ text }) {
  const startTime = performance.now()
  while (performance.now() - startTime < 1) {}   // block for 1ms
  return <li>{text}</li>
})

function generateItems(query) {
  return Array.from({ length: 500 }, (_, i) => `Item ${i + 1}: ${query}`)
}

export default function FilterDemo() {
  const [query, setQuery] = useState('')
  const [items, setItems] = useState(() => generateItems(''))
  const [isPending, startTransition] = useTransition()

  function handleChange(e) {
    const value = e.target.value
    setQuery(value)

    startTransition(() => {
      setItems(generateItems(value))
    })
  }

  return (
    <div>
      <input
        placeholder="Type to filter…"
        value={query}
        onChange={handleChange}
        style={{ fontSize: 18, padding: 8 }}
      />

      <p>
        Status:{' '}
        <strong>{isPending ? 'Rendering list…' : 'Up to date'}</strong>
      </p>

      <ul style={{ opacity: isPending ? 0.5 : 1 }}>
        {items.map((item, i) => (
          <SlowItem key={i} text={item} />
        ))}
      </ul>
    </div>
  )
}
useTransition vs Suspense

useTransition and Suspense are complementary, not competing:

Feature

useTransition

Suspense

What it handles

CPU-bound state updates (re-renders)

Data fetching / async resources

Trigger

You call startTransition()

A component throws a Promise

Loading UI

isPending flag — you control it

fallback prop on nearest <Suspense>

Shows old UI?

Yes — old UI stays visible until ready

Yes — in transition mode

React 18 only?

Yes

Available since React 16, enhanced in 18

They work together beautifully: when you navigate to a tab that fetches data wrapped in Suspense, wrap the setActiveTab call in startTransition so the old tab stays visible while both the re-render and the data fetch complete.

Common Use Cases
  • Tab switching — keep the current tab visible while the next tab renders

  • Search / filter — let the input respond instantly; defer the heavy list update

  • Page-level navigation — keep the current page visible during the next page render

  • Large form resets — instantly clear the form fields; defer resetting downstream state

  • Chart / data visualization updates — keep old chart visible while new one renders

Warning
Do not wrap urgent updates in startTransition. User interactions like typing, clicking, and form submission should always be treated as urgent. Only wrap work that updates something the user can afford to wait a moment for.
useTransition in React Router / Next.js

JSX
'use client'

import { useRouter } from 'next/navigation'
import { useTransition } from 'react'

function NavLink({ href, children }) {
  const router = useRouter()
  const [isPending, startTransition] = useTransition()

  function navigate() {
    startTransition(() => {
      router.push(href)
    })
  }

  return (
    <button onClick={navigate} disabled={isPending}>
      {isPending ? 'Loading…' : children}
    </button>
  )
}
Tip
Next.js 14+ App Router uses useTransition internally for route transitions. You can access isPending through the useRouter hook's navigation state, but wrapping your own router.push() in startTransition gives you direct control over the loading indicator.
Key Takeaways
  • useTransition marks a state update as low-priority so React can interrupt it for more urgent work

  • The input (or any urgent UI) stays responsive; only the deferred part may lag

  • isPending lets you show a loading indicator for the duration of the transition

  • Transitions are for CPU-bound rendering work — use Suspense for data-fetching async work

  • If the user triggers another transition before the first finishes, React discards the in-progress render