You type a letter into a search box filtering 8,000 rows. The letter shows up half a beat after your finger leaves the key. Not broken — just behind, like the page is thinking it over before it lets you see what you typed.
Everyone's first move is the same: throw a debounce on it. Wait 300ms after the last keystroke, then filter. It ships. It feels fine in the demo.
Then someone types fast, or the list grows, and the lag is back — just relocated. Debouncing didn't fix the freeze. It hid it behind a timer.
The bug, and why debounce only postpones it
Here's the naive version. An input drives a filter over a big array, and every keystroke re-renders both.
function ProductSearch({ products }) {
const [query, setQuery] = useState("");
const filtered = products.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ProductList items={filtered} />
</>
);
}
One setQuery call does two jobs: it updates the character you just typed, and it triggers React to re-render a list that might be thousands of rows deep. Both happen in the same synchronous render. The browser can't paint your new keystroke until it's done computing the whole list, because React doesn't yet know the two are different kinds of urgent.
Debounce doesn't change that relationship — it just changes when the expensive part runs. Pause for 300ms and start typing again, and the delayed render lands right on top of your next keystrokes, so you get the same freeze, timed differently. And now there's a second cost: the list visibly lags 300ms behind what you typed, which reads as buggy even when it's "working as designed."
The real problem was never when the filter runs. It's that the input update and the list update are forced to share one priority — urgent.
What's actually blocking the keystroke
Open React DevTools' profiler on that naive version and filter while it's recording. You'll see one commit per keystroke, and its duration scales with the list size, not the input. The character you typed was ready to paint instantly — it just had to wait in line behind a re-render it didn't ask for.
That's the tell: the input isn't slow. The scheduling is. React treats every update from one event as equally urgent, so re-rendering 8,000 rows blocks a one-character paint.
The fix that isn't a workaround
React 18 shipped a way to say this directly: some updates are urgent (what the user just typed, it must feel instant) and some aren't (the derived list, which can be a frame or two late without anyone noticing). useTransition is that split.
function ProductSearch({ products }) {
const [query, setQuery] = useState("");
const [filterQuery, setFilterQuery] = useState("");
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const value = e.target.value;
setQuery(value); // urgent: paint the keystroke now
startTransition(() => {
setFilterQuery(value); // not urgent: React can interrupt this render
});
}
return (
<>
<input value={query} onChange={handleChange} />
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<ProductList products={products} query={filterQuery} />
</div>
</>
);
}
const ProductList = memo(function ProductList({ products, query }) {
const filtered = products.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
);
return <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
});
setQuery runs outside the transition, so React paints your keystroke on its normal, fast path — no timer involved. setFilterQuery runs inside startTransition, which tells React: this update is real, it will happen, but if something more urgent shows up (your next keystroke), interrupt this render and restart it. Two details make that work. The filtering happens inside ProductList's render, not in the handler — startTransition calls your callback immediately, so anything expensive you compute there still runs synchronously and blocks the keystroke; only the render it triggers becomes interruptible. And ProductList is wrapped in memo: the urgent keystroke render re-renders ProductSearch, and without memo it would drag all 8,000 rows along at urgent priority. isPending flips true while a transition is in flight, so the wrapper div dims the stale list — it lives outside ProductList so the flag doesn't change the list's props.
No timer to tune. No stale-for-300ms window. If you type three characters in a row, React can abandon the half-finished list render for character one and jump straight to rendering for character three — something debounce can't do, because once its render starts, it runs to the end.
If you'd rather not keep two copies of the query, useDeferredValue(query) gives you the same split from a single state variable — and it's the tool to reach for when the query arrives as a prop you don't own. Same memo requirement either way.
Where this earns its keep — and where it doesn't
This is for derived work that's allowed to lag a frame behind its trigger: search-as-you-type over a big local dataset, a chart that re-renders off a slider, a tab switch that mounts a heavy view. Anywhere "the input feels instant, the result catches up" is an acceptable trade.
It's not a fix for a slow network request — that's not a scheduling problem, it's a waiting problem, and isPending there is just a nicer loading flag. And it's not a substitute for useMemo or actually reducing the work: if your filter itself is doing something absurd (recompiling a regex every keystroke, say), fix that first. useTransition reschedules expensive work; it doesn't make the work cheaper.
Try the naive version and the transition version side by side — same 8,000-row list, same input, and a live counter of how many stale renders got interrupted instead of blocking your typing.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The keystroke was never the slow part. The list update pretending to be as urgent as the keystroke was. Once React knows the difference, it stops making you choose between "fast typing" and "correct results" — you get both: the keystroke in one render, the list in the next.
Where else in your app is one setState secretly doing two jobs at two different urgencies? I'd bet you can name one from memory.
📚 Read next
- Debounce and Throttle in JavaScript: The Complete Guide
- The Background Task That Waited 40 Seconds for 'Idle'
- Web Workers in JavaScript: The Complete Guide
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)