A colleague changes a React controlled input's handler to startTransition(() => setQuery(e.target.value)), and now typing feels laggy with characters appearing late. What is wrong, and what is the fix?
answer
- the value attribute reads that state
- non-urgent means it can be postponed
- React forbids this for text inputs
- split the state, or defer the copy
- the echo stays urgent, the list lags
basics
~20 sThe input's displayed text comes from that state, and the transition marked it non-urgent, so React may delay or restart the render that shows the typed character. React's rule is that transitions cannot control a text input. Keep the input's state urgent and defer only the expensive derived work.
solid answer
~40 sThe input is controlled, so what the user sees is whatever `query` currently holds — and that update has just been marked non-urgent. React is now allowed to postpone or interrupt the render that puts the new character on screen, which shows up as characters landing late or briefly out of order under fast typing. React's documented caveat is exactly this: transitions cannot be used to control text inputs. The fix is to keep the update that feeds `value` urgent and move only the expensive consequence off the urgent path — either call `setQuery` normally and wrap a second, list-driving state update in `startTransition`, or keep one state and pass `useDeferredValue(query)` to the expensive component. Both preserve immediate echo of the keystroke while letting the heavy render lag.
code
javascript · 20 linesimport { memo, useDeferredValue, useState } from 'react';
const Results = memo(function Results({ query }) {
const rows = [];
for (let i = 0; i < 30000; i++) {
if (String(i).includes(query)) rows.push(i);
}
return <p>{rows.length} rows</p>;
});
export default function Search() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<Results query={deferredQuery} />
</div>
);
}go deeper
Recall that a controlled input shows whatever the state holds, so making that state update non-urgent makes the visible text lag. Say that the input's own update must stay urgent.
Explain that non-urgent renders can be interrupted and restarted, so under fast typing the render for one character is abandoned for the next, and name React's rule that transitions cannot control text inputs.
Diagnose before rewriting — isolate whether the latency comes from priority or from raw render cost — then pick between splitting the state and deferring the value, and say what each costs.
Set the boundary as a rule others can follow: direct manipulation stays urgent, consequences may lag. Also insist that priority scheduling is not a licence to skip reducing the actual render work.
## Why this specific bug appears A controlled input renders as `<input value={query} onChange={...} />`. Its displayed text is not the browser's own — React overwrites it with whatever `query` holds at commit time. So the latency the user feels when typing is exactly the latency of committing the state update that produced `query`. Wrapping that update in `startTransition` labels it non-urgent. React is then free to leave the render unfinished while it handles other urgent work, and to throw away in-progress non-urgent work when a newer update arrives. Under fast typing, each keystroke supersedes the last, so the render that would have shown character *n* is abandoned in favour of *n+1*, and the visible text falls behind the keyboard. React's documentation states the rule flatly: transitions cannot be used to control text inputs. ```js // broken: the visible text is itself non-urgent function onChange(e) { startTransition(() => setQuery(e.target.value)); } ``` The symptom is easy to misread as "the list is slow", because it usually appears together with a genuinely slow list — that is why someone reached for a transition in the first place. ## Fix 1 — split the state Keep the input's state urgent, and introduce a second state that drives the expensive subtree: ```js const [query, setQuery] = useState(''); const [filter, setFilter] = useState(''); const [isPending, startTransition] = useTransition(); function onChange(e) { const next = e.target.value; setQuery(next); // urgent: the input echoes startTransition(() => setFilter(next)); // non-urgent: the list } <input value={query} onChange={onChange} /> <Results filter={filter} /> ``` This is the shape to use when you also want `isPending`, for example to dim the list or disable a control while it catches up. The price is two pieces of state carrying the same information at different times: every reset, prefill, or programmatic change has to touch both, or they drift. ## Fix 2 — defer the value instead Keep a single source of truth and let the expensive consumer read a lagging copy: ```js const [query, setQuery] = useState(''); const deferredQuery = useDeferredValue(query); <input value={query} onChange={e => setQuery(e.target.value)} /> <Results query={deferredQuery} /> ``` The input reads `query` — urgent, immediate. `Results` reads `deferredQuery`, so on the keystroke render it still sees the previous string and, if it is wrapped in `memo`, skips re-rendering entirely; React then re-renders it in the background with the new string. Staleness is derivable as `query !== deferredQuery` if you want to fade the list. Note the mirror-image mistake: passing `deferredQuery` to the input's `value` reproduces the original bug by another route. The deferred copy exists for the expensive consumer, never for the control the user is typing into. ## Confirming the diagnosis Before rewriting, check that the lag really is priority and not raw render cost. Two cheap checks: type into the input with the expensive subtree temporarily removed — if it is smooth, the cost is in that subtree, not in the input; and remove only the `startTransition` wrapper — if typing becomes responsive while the list becomes janky, you have confirmed that the marking, not the workload, moved the latency onto the keystroke path. ## What neither fix does Neither approach reduces the work the list performs. If rendering the list takes 400 ms, it still takes 400 ms; you have only stopped that cost from sitting between the keypress and the echoed character. When the list itself is the problem — tens of thousands of rows, heavy per-row work — the real remedy is to render fewer rows or make each row cheaper, and the priority APIs are a complement to that, not a substitute. ## The general lesson Anything the user is directly manipulating — text they type, a checkbox they toggle, a control whose visual state is their own input echoed back — must stay on the urgent path. Non-urgent marking belongs on the *consequences* of that interaction: the filtered list, the recomputed chart, the switched panel. Getting that boundary wrong converts a performance tool into a responsiveness bug.
- Would passing the deferred value to the input's value attribute be an acceptable alternative?No — it recreates the same bug from the other direction. The input would display a value that intentionally lags behind the state, so typed characters appear late and the caret can jump. The deferred copy is for the expensive consumer only; the control the user types into always reads the live state.
- How would you confirm the lag is a priority problem rather than a genuinely slow input render?Remove the expensive subtree and type: if the input is smooth, the cost lives in that subtree. Then remove only the startTransition wrapper: if typing becomes responsive while the list becomes janky, the marking was moving latency onto the keystroke path. The Profiler can then show which commits are long and what they contain.
- When would you choose the two-state fix over the deferred-value fix?When you need `isPending` — a real pending affordance such as dimming the results or disabling a control — or when the expensive work is triggered by a discrete action rather than every keystroke. The deferred-value fix is better when you want one source of truth and the slow consumer is far from where the state lives.
- After the fix, the list still janks the page for hundreds of milliseconds. What now?Priority scheduling never reduced the work; it only moved it off the keystroke path. If a single background render is still that long, cut the work itself: render fewer rows, make each row cheaper, or narrow what the update touches. Marking work non-urgent is a complement to reducing it, not a replacement.
saying these in an interview costs you the question
- Says the transition dropped the state update entirely
- Blames the browser or the onChange event for the lag
- Suggests deferring the input's own value as the fix
- Claims transitions make any update slower by design
- Thinks adding memo to the input component solves it