React

Concurrent React: useTransition and useDeferredValue

Master concurrent React features useTransition and useDeferredValue to fix input lag on heavy filtered lists — no debounce hacks required.

By Laxaar Engineering Team Aug 12, 2026 11 min read
Concurrent React: useTransition and useDeferredValue

Input lag on a filtered list is one of the most common React performance complaints we hear from product teams. The symptom is familiar: a user types a character into a search box, and the UI stutters because React is busy re-rendering 2,000 list items before it can acknowledge the keystroke. The instinct is to reach for debounce or setTimeout. That instinct is wrong, and concurrent React features give us better tools.

React 18 shipped two hooks, useTransition and useDeferredValue, that let you tell React which state updates are urgent and which can wait. The problem is that most explanations treat them as interchangeable. They're not. They solve related but distinct problems, and picking the wrong one either wastes effort or leaves the lag unfixed.

We've profiled both hooks against a heavy filtered list at Laxaar and the results are clear. This post walks through what each hook actually does under the hood, when to reach for each, and the profiler evidence that settles the question.

What you'll learn

What concurrent React features actually are

Concurrent React is a set of rendering capabilities in React 18 that allow the runtime to pause, interrupt, and resume renders. Before React 18, every render was synchronous and blocking: once React started re-rendering a tree, nothing else could happen until it finished. That model breaks down when a single render takes 80ms or more, which is enough to make a UI feel unresponsive.

The concurrent model lets React split work into units, yield control back to the browser between units, and deprioritize renders that aren't urgent. useTransition and useDeferredValue are the two escape hatches that let your components opt specific updates into this lower-priority rendering lane.

Neither hook is about making your component faster in terms of raw computation. A heavy filtered list that takes 60ms to render takes 60ms regardless. The win is that React can yield in the middle of that 60ms render, let the browser handle a keypress or paint a cursor blink, and then continue. The total work is the same; the perceived responsiveness is dramatically better.

This is the honest trade-off: concurrent features improve perceived performance, not actual computational cost. If your bottleneck is raw computation (calculating a result, not rendering it), you need memoization or a web worker. These hooks won't help.

How useTransition works

useTransition is a hook that returns a tuple: a boolean isPending flag and a startTransition function. You wrap the state update that triggers a heavy render inside startTransition, and React treats that update as non-urgent.

import { useState, useTransition } from 'react'

function SearchableList({ items }: { items: string[] }) {
  const [query, setQuery] = useState('')
  const [filteredItems, setFilteredItems] = useState(items)
  const [isPending, startTransition] = useTransition()

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value
    setQuery(value) // urgent — updates the input immediately

    startTransition(() => {
      // non-urgent — React can interrupt and restart this
      setFilteredItems(items.filter(item =>
        item.toLowerCase().includes(value.toLowerCase())
      ))
    })
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <span>Updating...</span>}
      <ul>
        {filteredItems.map(item => <li key={item}>{item}</li>)}
      </ul>
    </>
  )
}

The key detail: useTransition requires you to own the state update. You call startTransition around a setState call. That means you need access to the setter. It's a state-owner API.

The isPending flag is genuinely useful. While React is working through the deferred render, isPending is true. You can use it to show a loading indicator, dim the list, or disable a submit button. It gives you visibility into the transition lifecycle that useDeferredValue doesn't provide.

React may abandon a transition mid-render if a new, higher-priority update comes in. If the user types another character while a transition render is in progress, React discards the in-progress work and starts fresh with the latest state. This is called "time-slicing." It's why you stop needing debounce for input responsiveness.

How useDeferredValue works

useDeferredValue is a hook that takes a value and returns a deferred copy of it. The deferred copy lags behind the real value: React renders your component first with the old value (the urgent render), then schedules a lower-priority render to update the deferred copy to the new value.

import { useState, useDeferredValue, useMemo } from 'react'

function SearchableList({ items }: { items: string[] }) {
  const [query, setQuery] = useState('')
  const deferredQuery = useDeferredValue(query)

  const filteredItems = useMemo(
    () => items.filter(item =>
      item.toLowerCase().includes(deferredQuery.toLowerCase())
    ),
    [items, deferredQuery]
  )

  const isStale = query !== deferredQuery

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ul style={{ opacity: isStale ? 0.6 : 1 }}>
        {filteredItems.map(item => <li key={item}>{item}</li>)}
      </ul>
    </>
  )
}

useDeferredValue is a value-consumer API. You don't control the state; you're given a value (perhaps from a prop or context) and you want to delay the heavy computation that depends on it. This is the critical difference from useTransition.

Note the useMemo wrapping the filter. Without it, filteredItems recomputes on every render (including the urgent one), which defeats the purpose. The memo ensures the expensive filter only runs when deferredQuery changes, which only happens in the low-priority render.

The isStale flag (query !== deferredQuery) lets you show visual feedback. We dim the list with reduced opacity while the deferred render is pending, which prevents the confusing flash of showing old results against a new query string.

useTransition vs useDeferredValue compared

The confusion between these hooks is understandable because they produce similar outcomes. Here's where they actually differ:

DimensionuseTransitionuseDeferredValue
What you controlA state setter callA derived value
Access neededMust own the setStateWorks on any value, including props
Pending signalisPending booleanCompare value vs deferred value manually
Typical locationEvent handler / actionComponent body or render
Works with external stateNo — you need the setterYes — wraps any value
Introduced inReact 18React 18

The practical rule: use useTransition when you own the state update. Use useDeferredValue when you receive a value you can't control (from a prop, from a context provider, or from a URL param) and want to defer the expensive work that depends on it.

A common real-world case for useDeferredValue: a parent component holds the search query, passes it as a prop to a heavy child list component, and the parent can't (or shouldn't) be restructured to use useTransition. The child wraps the prop in useDeferredValue and handles its own priority.

Profiling a heavy filtered list

Claims about performance need profiler evidence. Here's what we measured.

Test setup: a list of 5,000 items rendered as <li> elements, each with a moderately complex layout (icon, text, badge). Filtering runs on the main thread via .filter() with a case-insensitive string match. Tested on a mid-range Android device using Chrome DevTools performance throttling (4x CPU slowdown) to simulate real-world conditions.

Without any optimization: typing a single character triggered a synchronous render that blocked the main thread for ~85ms. The input field visually lagged by one to two characters. The browser dropped frames.

With debounce (300ms): input was responsive, but the list update was always delayed by 300ms. Noticeable and annoying for fast typers who expected immediate feedback.

With useTransition: input updated instantly on every keystroke. React interleaved the list render across multiple frames, yielding between items. Total render time was still ~85ms, but it was spread across ~6 frames so the browser never missed a user interaction. No stutter. No isPending display needed for this test.

With useDeferredValue + useMemo: identical user-facing result to useTransition for this scenario. The deferred value lagged one render behind the input, the memo prevented redundant filter runs, and the list updated smoothly.

The conclusion from profiling: both hooks fix input lag equally well when the state lives in the same component. useTransition is slightly simpler to reason about because isPending makes the transition lifecycle explicit. useDeferredValue shines when the state is elsewhere.

Debounce produced the worst user experience of the three despite being the most commonly reached-for solution.

When debounce still has a place

We're not saying debounce is useless. It still makes sense for network requests. If each keystroke fires an API call, concurrent React features don't help: the server round-trip is the bottleneck, not rendering. You want to debounce the API call so you're not hammering your backend with a request per character.

The pattern we use at Laxaar: debounce the API call, but use useTransition or useDeferredValue for any local rendering driven by the same query string. They solve different parts of the same problem.

import { useState, useTransition, useEffect, useRef } from 'react'

function SearchWithApi({ fetchResults }: { fetchResults: (q: string) => Promise<string[]> }) {
  const [query, setQuery] = useState('')
  const [apiResults, setApiResults] = useState<string[]>([])
  const [isPending, startTransition] = useTransition()
  const debounceRef = useRef<ReturnType<typeof setTimeout>>()

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value
    setQuery(value) // urgent input update

    // Debounce the API call
    clearTimeout(debounceRef.current)
    debounceRef.current = setTimeout(async () => {
      const results = await fetchResults(value)
      startTransition(() => {
        setApiResults(results) // non-urgent list update
      })
    }, 300)
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <span>Loading...</span>}
      <ul>
        {apiResults.map(r => <li key={r}>{r}</li>)}
      </ul>
    </>
  )
}

Debounce handles the network; startTransition handles the render. Both do their jobs without interfering with each other.

Common mistakes and trade-offs

Forgetting useMemo with useDeferredValue. The deferred value only helps if the expensive computation is memoized against it. Without useMemo, your component re-runs the filter on every render, urgent ones included, and you get no benefit.

Using startTransition for genuinely urgent updates. Marking a form submission or a navigation event as a transition is wrong. If the user clicks "Submit," they expect an immediate response. Transitions are for updates that display secondary or derived content, not for acknowledging user intent.

Expecting concurrent features to replace code-level optimization. If your filter function runs in O(n²) time across 50,000 items, no amount of scheduling will save you. Profile the computation first. If the work genuinely takes 200ms of CPU time, you need to move it to a web worker or improve the algorithm before concurrent features can help.

Nesting startTransition inside another startTransition. React flattens nested transitions; there's no priority queue within the transition lane. Don't rely on nested transitions for ordering guarantees.

If your team is building a complex React product, our web development services include React performance audits. We profile real user traces and pick the right fix: concurrent scheduling, memoization, or an architecture change.

Frequently Asked Questions

Do useTransition and useDeferredValue work in React Server Components?

No. Both hooks are client-side APIs that rely on React's concurrent rendering scheduler, which only runs in the browser. Server Components render synchronously on the server and send HTML or RSC payload to the client. If you need these hooks, the component must be a Client Component (marked with 'use client').

Does useTransition work with React 19's new use() hook and async transitions?

Yes, and this is one of React 19's most useful upgrades. In React 19, startTransition accepts async functions. You can await a server action or data fetch inside startTransition, and React keeps isPending true for the full async duration. This replaces a lot of manual loading state management that previously required separate useState calls.

Can I use useDeferredValue without useMemo?

You can, but you likely won't get the intended benefit. Without useMemo, the expensive computation runs on every render regardless of whether the deferred value changed. The combination of useDeferredValue + useMemo is the canonical pattern: the deferred value limits how often the memo dependency changes, and the memo ensures the computation only runs when it does.

Is there a performance cost to these hooks themselves?

Small, yes. useTransition adds a scheduler overhead per transition. useDeferredValue causes React to render the component twice per state change: first with the old deferred value (urgent), then again with the new value (deferred). On most components this is negligible. On a deeply nested tree with many concurrent renders firing simultaneously, the double-render pattern can add up. Profile before adding these hooks everywhere.

Should I add useTransition to every state update?

No. Marking everything as a transition means nothing is urgent, and React loses the signal it needs to prioritize correctly. Use transitions only for updates that produce secondary visual output: list filtering, chart updates, non-critical UI panels. Keep urgent updates (input values, button feedback, navigation) outside of startTransition.


Working on a React app with rendering performance problems? The Laxaar team has profiled and fixed concurrent rendering issues across products of all sizes, from startup MVPs built through our MVP development service to large-scale SaaS platforms. Get in touch and we'll tell you whether concurrent features, memoization, or a different architecture is the right fix for your specific case.

Working on something like this?

Get a fixed scope, timeline, and price within one business day — no obligation.

ReactConcurrent ReactPerformance
Grow your business with us

Take your business to the next level.

Tell us what you're building. We'll come back inside one business day with a fixed scope, timeline, and team — or an honest “this isn't a fit”.

ENGINEERING PHILOSOPHY

Code is useless if it's not comprehensible to those who maintain it. We write code the next person can actually understand.