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
handleChangecallssetTextdirectly and synchronously — this is the urgent part, and it's what keeps the input itself feeling instant.- The actual list update,
setVisible(...), is wrapped instartTransition. React renders it at lower priority and can interrupt it iftextchanges again before it commits. isPendingistruefor the whole time a transition is in flight; the snippet uses it to dim the list rather than block the input.visiblestarts asitemsand 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 scatterawaitthrough 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
itemsarrays, 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.