React useTransition and useDeferredValue: Concurrent React (2026)

React 18's concurrent features let you mark certain state updates as non-urgent, keeping the UI responsive while expensive rendering happens in the background. useTransition and useDeferredValue are the two hooks for this — they look similar but serve different purposes. Understanding when to use each one is key to writing performant concurrent React.

The Concurrent Rendering Mental Model

Without concurrent features, React renders synchronously — every state update blocks the main thread until the entire component tree re-renders. A 200ms render freezes the UI completely.

With concurrent rendering, React can:

  • Interrupt a render to process a higher-priority update (e.g. user typing)
  • Defer an expensive render until the browser is idle
  • Show stale content while new content prepares, rather than a loading spinner

useTransition

useTransition marks state updates inside its callback as transitions — low priority updates that can be interrupted:

'use client'
import { useState, useTransition } from 'react'

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

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

    // Urgent: update the input immediately
    setQuery(value)

    // Non-urgent: defer the expensive search results update
    startTransition(() => {
      const filtered = heavyFilter(allItems, value)
      setResults(filtered)
    })
  }

  return (
    <div>
      <input value={query} onChange={handleChange} placeholder="Search..." />

      {/* Show visual hint while transition is pending */}
      <div style={{ opacity: isPending ? 0.6 : 1, transition: 'opacity 0.2s' }}>
        <ResultsList results={results} />
      </div>

      {isPending && <span className="text-sm text-gray-500">Updating...</span>}
    </div>
  )
}
Key behaviour: The input updates instantly (user sees their typing). React starts rendering the new results list but can interrupt it if the user types again. The stale results stay visible with reduced opacity until the new results are ready.

Tab switching — common useTransition pattern:

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

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

  return (
    <div>
      <nav>
        {['overview', 'analytics', 'settings'].map(tab => (
          <button
            key={tab}
            onClick={() => selectTab(tab)}
            style={{ fontWeight: activeTab === tab ? 'bold' : 'normal' }}
          >
            {tab}
          </button>
        ))}
      </nav>

      {/* Tab content dims while new tab is preparing */}
      <div style={{ opacity: isPending ? 0.7 : 1 }}>
        {activeTab === 'overview' && <SlowOverview />}
        {activeTab === 'analytics' && <SlowAnalytics />}
        {activeTab === 'settings' && <Settings />}
      </div>
    </div>
  )
}

startTransition (without hook)

Use the imported startTransition when you don't need isPending — e.g. in event handlers outside components:

import { startTransition } from 'react'

// In a router, store, or utility function
function navigateTo(path) {
  startTransition(() => {
    setCurrentPath(path)  // Mark navigation as a transition
  })
}

// In React 19, actions are automatically treated as transitions
async function submitForm(formData) {
  // React 19: async functions passed to form action are transitions
  await saveToServer(formData)
  setSuccess(true)
}

useDeferredValue

useDeferredValue defers re-rendering a component when its props change — React renders with the old value first (instant) then schedules the re-render with the new value when idle:

'use client'
import { useState, useDeferredValue, memo } from 'react'

function SearchResults({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query)

  // isStale: true while deferred value lags behind current value
  const isStale = query !== deferredQuery

  return (
    <div style={{ opacity: isStale ? 0.7 : 1 }}>
      {/* Only re-renders when deferredQuery changes */}
      <ExpensiveList filter={deferredQuery} />
    </div>
  )
}

// Memoize the expensive component — only re-renders when deferredQuery changes
const ExpensiveList = memo(function({ filter }: { filter: string }) {
  // Simulate expensive filtering
  const items = allItems.filter(item =>
    item.name.toLowerCase().includes(filter.toLowerCase())
  )
  return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>
})

// Parent — input stays responsive, list updates are deferred
function SearchPage() {
  const [query, setQuery] = useState('')

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <SearchResults query={query} />
    </div>
  )
}

useTransition vs useDeferredValue

Feature useTransition useDeferredValue
You controlWhich state updates are deferredWhich value update is deferred
Where it livesWhere the state is updated (event handler)Where the value is consumed (receiving component)
isPendingYes — built inManual: value !== deferredValue
Use whenYou own the state update (same component)You receive a prop/value from a parent
Typical use caseTab switching, navigation, filter togglesLive search lists, real-time visualizations

Practical Patterns

Optimistic UI with useTransition:

function LikeButton({ postId, initialLikes }) {
  const [likes, setLikes] = useState(initialLikes)
  const [isPending, startTransition] = useTransition()

  function handleLike() {
    // Optimistic: update immediately
    setLikes(l => l + 1)

    startTransition(async () => {
      try {
        await apiLikePost(postId)
      } catch {
        // Rollback on error
        setLikes(l => l - 1)
      }
    })
  }

  return (
    <button onClick={handleLike} disabled={isPending}>
      ❤️ {likes}
    </button>
  )
}

Deferred chart rendering:

function Dashboard({ dateRange }) {
  const deferredRange = useDeferredValue(dateRange)
  const isUpdating = dateRange !== deferredRange

  return (
    <div>
      <DateRangePicker value={dateRange} />
      <div style={{ filter: isUpdating ? 'blur(2px)' : 'none', transition: 'filter 0.2s' }}>
        <HeavyChart range={deferredRange} />
      </div>
    </div>
  )
}

With Suspense

function App() {
  const [tab, setTab] = useState('home')
  const [isPending, startTransition] = useTransition()

  // With Suspense: startTransition prevents Suspense from showing
  // the fallback when switching tabs — it stays on the old tab
  // until the new one is ready, then switches instantly
  return (
    <div>
      <TabBar onSelect={tab => startTransition(() => setTab(tab))} isPending={isPending} />
      <Suspense fallback={<Spinner />}>
        {tab === 'home' && <Home />}
        {tab === 'profile' && <Profile />}
      </Suspense>
    </div>
  )
}

Next.js and Server Actions

'use client'
import { useTransition } from 'react'
import { updateProfile } from '@/actions/profile'

function ProfileForm({ profile }) {
  const [isPending, startTransition] = useTransition()

  function handleSubmit(e) {
    e.preventDefault()
    const formData = new FormData(e.target)

    startTransition(async () => {
      // Server Action inside a transition — React tracks pending state
      await updateProfile(formData)
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" defaultValue={profile.name} />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Saving...' : 'Save Changes'}
      </button>
    </form>
  )
}