C
React/Optimization/Lesson 18

useTransition / useDeferredValue — Push Heavy Updates to the Background

30 min·theory
This chapter
1/2
TypeScript

useTransition / useDeferredValue — Push Heavy Updates to the Background

💡 Why Learn This? — Why Input Stutters

🎯 When you type quickly in a search box, the input feels sluggish — because the value change is processed at the same priority as heavy filtering and rendering.
💼 useTransition tells React: 'This state update is not urgent — the input matters more.'
React shows the input update first and yields the heavy update to the background → the input becomes smooth.
🔗 useDeferredValue achieves a similar effect at the 'value' level — a pattern where only the deferred value is passed down to a heavy child component.
📈 UX problems that used to be handled with debounce or throttle are now solved by React through time slicing.
🏢 실무에서는
Search autocomplete, large list filtering, chart data changes, heavy markdown previews — all fall within the domain of useTransition. Users only notice that 'the input is smooth,' while the heavy parts are rendered in the background automatically.

useTransition · useDeferredValue · startTransition

1. useTransition — Mark a setState as 'yieldable'

tsx
const [isPending, startTransition] = useTransition();

startTransition(() => {
  setResults(filter(query)); // 'yieldable'
});
  • setState calls inside startTransition(fn) are marked as yieldable.
  • React processes more urgent updates (input, clicks) first if they arrive.
  • Use isPending to check whether a yielded update is in progress — show a spinner.

2. useDeferredValue — A 'deferred copy' of a value

tsx
const deferredQuery = useDeferredValue(query); // follows one beat behind

<HeavyResults query={deferredQuery} />
  • query updates immediately (smooth input).
  • deferredQuery follows when React has spare capacity.
  • Even if HeavyResults does heavy work with deferredQuery, input remains unaffected.

3. When to use which?

SituationRecommended
You own the setState call (direct control)useTransition
You pass a received value to a child, and that child is heavyuseDeferredValue
You need the yielding state (isPending)useTransition
You need both the 'latest' and 'one beat behind' value at onceuseDeferredValue

4. startTransition — Callable without the hook

ts
import { startTransition } from 'react';

button.addEventListener('click', () => {
  startTransition(() => setData(newData));
});
// Cannot track isPending; only marks the update as yieldable

5. Caveats

  • If the work is truly heavy (over 1 second), useTransition alone is not enough — a Web Worker may be required.
  • fetch itself cannot yield — only the setState that processes the fetch response can yield.
  • These are merely 'scheduling hints' — React only yields when it actually can.
💻 🅰️ Plain setState — Input Stutters
// ❌ Without yielding — 50,000 items filtered on every input
import { useState } from 'react';

const HUGE = Array.from({ length: 50000 }, (_, i) => `item-${i}`);

export function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value);
    const filtered = HUGE.filter(d => d.includes(e.target.value));
    setResults(filtered);
    // If typing fast, input lags by one character (jank)
  };

  return (
    <div>
      <input value={query} onChange={onChange} />
      <ul>{results.slice(0, 100).map(r => <li key={r}>{r}</li>)}</ul>
    </div>
  );
}
💻 🅱️ useTransition / useDeferredValue — Smooth Input
// ✅ useTransition — Yielding filtering
import { useState, useTransition, useDeferredValue } from 'react';

const HUGE = Array.from({ length: 50000 }, (_, i) => `item-${i}`);

export function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>(HUGE);
  const [isPending, startTransition] = useTransition();

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value); // Urgent

    startTransition(() => {
      // Non-urgent — can yield
      const filtered = HUGE.filter(d => d.includes(e.target.value));
      setResults(filtered);
    });
  };

  return (
    <div>
      <input value={query} onChange={onChange} />
      {isPending && <p>Updating...</p>}
      <ul>{results.slice(0, 100).map(r => <li key={r}>{r}</li>)}</ul>
    </div>
  );
}

// Or useDeferredValue — when child is heavy
export function SearchAlt() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

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

function HeavyList({ query }: { query: string }) {
  // query is deferred, so it doesn't affect input
  const filtered = HUGE.filter(d => d.includes(query));
  return <ul>{filtered.slice(0, 100).map(r => <li key={r}>{r}</li>)}</ul>;
}

💡 💡 useTransition / useDeferredValue — 5 Practical Tips

1. Input is immediate, results yield — the most common pattern

tsx
setQuery(input);                  // urgent
startTransition(() => setResults(filter(input))); // yieldable

2. Show progress with isPending

tsx
{isPending && <Spinner />}

3. Processing a fetch response can yield, but the fetch itself cannot

tsx
const data = await fetch(url).then(r => r.json());
startTransition(() => setData(data));

4. useDeferredValue = per 'received value' unit, useTransition = per 'your own call' unit
If props received from a parent change frequently and the child consuming them is heavy, use useDeferredValue.

5. A partial substitute for debounce, not a complete one
useTransition uses time-slicing; debounce reduces the number of calls. Typically choose one or the other depending on the situation.

⚡ Try It Yourself — startTransition Flow

Simulates the priority between urgent and yieldable updates.
✏️ JS 코드
📟 Console output
▶ Press the Run button
⚠️ Runs in a browser sandbox — only console.log() is supported; alert/fetch are not.

Check Quiz

While typing in a search box, autocomplete rendering is so heavy that input stutters. What is the most appropriate tool to use?
💡 useTransition (yielding setState directly) or useDeferredValue (delaying a received value by one beat) is the correct answer in React 18+. Input updates are reflected immediately as urgent, while heavy autocomplete rendering is marked as interruptible and handled smoothly by React via time slicing. useMemo is for caching; setTimeout is the old debounce pattern.
useTransition / useDeferredValue - React