← Debugging Guides

阅读中文版本

React — Controlled Input Lags on Every Keystroke

Fix slow typing in React controlled inputs caused by re-rendering large subtrees per keystroke, profile the render cost, and split state scope or debounce persistence.

intermediatedebuggingreactperformanceinputs

Symptoms

  • Typing in a search or editor field feels delayed; the caret and characters arrive visibly late
  • React DevTools Profiler shows a large component tree re-rendering on every keystroke
  • The lag grows with list size, page complexity, or expensive child components

Possible causes

  • Controlled state lives high in the tree, so each keystroke re-renders everything below it
  • Children lack memoization, and every render re-runs expensive computation without useMemo
  • Expensive work runs inline in render: filtering thousands of rows per keystroke
  • State persistence (syncing to server or localStorage) fires per keystroke instead of debounced

Diagnosis steps

Profile one keystroke

React DevTools → Profiler → record → type one character → stop

Interpretation

The flame chart shows the commit cost and which components re-rendered; anything beyond the input subtree is waste.

Find the state ownership

grep -Rn "useState(\|useForm\|useController" src/ | head -15

Interpretation

The state that changes per keystroke and its position in the tree determines the blast radius of every character.

Measure the inline computation

wrap suspicious computations in performance.now() timing during a keystroke

Interpretation

Filtering or sorting thousands of rows inside render shows up as tens of milliseconds per keystroke — the dominant cost.

Root cause

A controlled input re-renders its owner on every keystroke. When that owner is high in the tree and the subtree below is large or expensive, each character pays a full reconciliation of unrelated components, and typing latency grows with everything the state change touches.

Fix

Push input state down to the smallest component that needs it, memoize expensive children and computations, defer heavy filtering with useMemo on committed values plus debounced search, keep persistence debounced, and for large editors consider uncontrolled inputs with refs or state management outside React render.

Risk note

Splitting state away from dependent UI desynchronizes views if done blindly. Keep shared state shared; push down only keystroke-local state.

Verify the fix

Typing in a 10k-row page feels immediate: profiler commits per keystroke stay under a few milliseconds and limited to the input subtree.

Symptom walkthrough

The Profiler makes the blast radius visible: record one keystroke, and the flame chart shows the commit rendering the entire page, not the input. The keystroke-to-paint path is the diagnosis:

keystroke -> setState -> re-render App + 400 components -> paint
                                    ^ everything here is waste

The ownership question locates the root cause: where does the value state live? High placement with expensive descendants means every character reconciles the world. And the inline computation check separates render waste from compute waste: a filter over ten thousand rows inside the render body costs tens of milliseconds per keystroke regardless of tree size.

Fix in practice

Push the state down to the smallest owner:

function SearchPage({ rows }) {
  return (
    <>
      <FilterBar rows={rows} />   {/* input state lives here */}
      <ResultsTable rows={rows} /> {/* not re-rendered per keystroke */}
    </>
  );
}

const FilterBar = memo(function FilterBar({ rows }) {
  const [query, setQuery] = useState('');
  const deferred = useDeferredValue(query);
  const results = useMemo(
    () => filterRows(rows, deferred),
    [rows, deferred]
  );
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
});

Debounce what leaves the component:

useEffect(() => {
  const t = setTimeout(() => saveDraft(value), 500);
  return () => clearTimeout(t);
}, [value]);

And for heavy editors (code, rich text), uncontrolled input plus ref reads, with state committed on blur or explicit save, removes typing from the render path entirely.