10LOC
Reactintermediate

useTransition to keep input responsive during expensive updates

Published September 1, 2026

import { useState, useTransition } from "react";

export const FilterableList = ({ items }: { items: string[] }) => {
  const [text, setText] = useState("");
  const [visible, setVisible] = useState(items);
  const [isPending, startTransition] = useTransition();

  const handleChange = (value: string) => {
    setText(value); // urgent: keeps the input itself responsive
    startTransition(() => setVisible(items.filter((item) => item.includes(value))));
  };

  return (
    <div style={{ opacity: isPending ? 0.6 : 1 }}>
      <input value={text} onChange={(e) => handleChange(e.target.value)} />
      <ul>{visible.map((item) => <li key={item}>{item}</li>)}</ul>
    </div>
  );
};

What

useTransition marks a state update as low-priority so React can keep the UI responsive while it works through it, and gives you a boolean to know when that background update is still in flight.

Why it matters

Filtering a large list on every keystroke is a common source of janky inputs: the browser has to finish rendering the new list before it can show the character you just typed. Wrapping the expensive update in startTransition tells React "this can wait" — urgent updates (the keystroke itself) still render immediately, and React works on the transition in the background, discarding a stale attempt if a newer one comes in before it finishes. The alternative — doing everything synchronously and hoping it's fast enough — falls apart the moment the list or the filter logic grows.

How it works

  • handleChange calls setText directly and synchronously — this is the urgent part, and it's what keeps the input itself feeling instant.
  • The actual list update, setVisible(...), is wrapped in startTransition. React renders it at lower priority and can interrupt it if text changes again before it commits.
  • isPending is true for the whole time a transition is in flight; the snippet uses it to dim the list rather than block the input.
  • visible starts as items and is only replaced by the filtered array once the transition commits — there's no separate "loading" state to manage by hand.

Gotchas

  • startTransition's callback must be synchronous, or — since React 19 — an async function used as an Action. Don't scatter await through ordinary transition logic and expect the rest to still count as part of the transition.
  • A transition doesn't guarantee anything finishes by a specific time; it's a priority hint, not a debounce or a deadline. For genuinely large items arrays, you still need a faster filter (indexing, virtualization) underneath it.
  • Marking every update as a transition defeats the purpose — only wrap the update that's actually expensive, so unrelated fast updates still feel instant.

Related

  • useDeferredValue — the value-oriented sibling of this hook; use it when you're deriving one value from another rather than triggering an update from an event handler.
  • useOptimistic — also built on transitions, but for showing a predicted result of an async action rather than delaying an expensive synchronous one.