Haliviq
React

Wrap heavy list re-renders with useDeferredValue instead of debouncing

Debounce only delays the update, so the expensive render still blocks the page once it fires. useDeferredValue lets React interrupt that render when a new keystroke arrives, so typing stays smooth even on a 5,000-row table. Reach for it when a filter box drives a big list and you do not want to add arbitrary wait times.

What happened

On one of our projects, a filter box drove a table of 5,000 rows. We had already put a debounce on the input, and typing felt fine until the delay expired. At that moment the whole page froze for a beat while the table re-rendered.

Debounce had not removed the expensive work, it had only moved it to a later time. Whenever the timer fired, the user was locked out of the page until React finished rendering.

Why it happens

Debounce changes when the state update happens, but once the update runs, the render is exactly as big as before. A normal state update is urgent, so React works through it without interruption, and any keystrokes typed in the meantime wait in the queue.

useDeferredValue marks a value as lower priority. React first re-renders with the old value, which is cheap, then starts a background render with the new value. If another keystroke arrives, React abandons the render in progress and restarts with the latest value, so the input stays responsive.

What we do now

We now keep the input bound to the immediate state and pass only useDeferredValue(query) to the list. The list is wrapped in memo, because otherwise the child re-renders with the old value too and nothing is saved. Comparing query with the deferred value also lets us dim the list while it catches up.

Debounce is still the right tool when each update triggers a network request, since useDeferredValue does not reduce the number of requests. When the cost is purely client-side rendering, it removes the arbitrary wait time.

Input uses the live value, the list uses the deferred onetsx
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const isStale = query !== deferredQuery

return (
  <>
    <input value={query} onChange={e => setQuery(e.target.value)} />
    <div style={{ opacity: isStale ? 0.6 : 1 }}>
      <HeavyList query={deferredQuery} />
    </div>
  </>
)

// HeavyList must be wrapped in memo
const HeavyList = memo(function HeavyList({ query }: { query: string }) {
  // render rows filtered by query
  return null
})

Quick checklist

  1. 1Keep the input value in normal state so typing is never delayed.
  2. 2Create deferredQuery with useDeferredValue(query).
  3. 3Pass deferredQuery to the heavy list and wrap the list in memo.
  4. 4Show a dimmed state while query differs from deferredQuery.

Rule of thumb

Use useDeferredValue to make an expensive render interruptible, and keep debounce for cutting down network requests.

We'd love to hear what you're building.

Talk to us