A teammate flagged that our dashboard's search box felt genuinely laggy, a visible half-second delay between typing a character and seeing it appear, on a feature that should have been instant. I opened React DevTools expecting a quick fix and instead found the entire dashboard, every chart, every table row, every sidebar item, re-rendering on every single keystroke, not just the search input itself.
React DevTools' Profiler tab records a session and shows exactly which components rendered and why, and recording a few keystrokes into the search box lit up nearly the entire component tree in the flame graph, confirming this wasn't a slow search function, it was the whole page repainting unnecessarily every time.
The search input's state lived in the top-level dashboard component, meaning every keystroke updated state there and triggered a re-render of every single child component beneath it, including charts that had nothing to do with search at all.
// before, state lived at the very top
function Dashboard() {
const [search, setSearch] = useState('');
return (
<>
<SearchBox value={search} onChange={setSearch} />
<ExpensiveChart />
<DataTable />
</>
);
}
I moved the search state into a small wrapper component that only contained the search box and the filtered results list, leaving the unrelated chart and table components outside that state's reach entirely.
function Dashboard() {
return (
<>
<SearchSection />
<ExpensiveChart />
<DataTable />
</>
);
}
function SearchSection() {
const [search, setSearch] = useState('');
return (
<>
<SearchBox value={search} onChange={setSearch} />
<FilteredResults query={search} />
</>
);
}
This single change alone removed the chart and table from the re-render path entirely, since their parent no longer held the state that was changing on every keystroke.
For a couple of remaining components that still received props from a parent that updated occasionally, wrapping them in React.memo stopped unnecessary re-renders when their specific props hadn't changed. I initially wrapped nearly every component in memo hoping it would help broadly, and found it made no measurable difference on components whose props were changing on every render anyway, since memo only helps when the actual props are stable.
One memoized component kept re-rendering despite the wrapper, and I eventually found an inline arrow function being passed as a prop, creating a brand new function reference on every parent render, which memo's shallow comparison correctly identified as a genuinely changed prop every single time.
// this defeats memo, a new function every render
<ExpensiveChart onSelect={(id) => handleSelect(id)} />
// wrapping the handler in useCallback fixed it
const handleSelectMemo = useCallback((id) => handleSelect(id), []);
<ExpensiveChart onSelect={handleSelectMemo} />
Beyond fixing the re-render scope, I also debounced the actual filtering logic itself, since running a filter function against a large dataset on every single keystroke was still genuinely wasteful even once it was scoped to just the search section.
const debouncedQuery = useDeferredValue(search);
Using React's built-in useDeferredValue here let the input itself stay instantly responsive while the actual filtered list update lagged slightly behind, a genuinely smoother feel than filtering synchronously on every keystroke.
Re-running the same Profiler recording after all these changes showed the flame graph reduced from nearly the entire tree lighting up per keystroke to just the search section itself, and the visible typing lag my teammate had flagged was gone entirely once I tested it myself.
Digging further, I found a theme context provider wrapping the entire dashboard whose value prop was a freshly created object on every render of its parent, meaning every single consumer of that context re-rendered whenever anything upstream changed, regardless of whether the actual theme value had changed at all. Wrapping that context value in useMemo so it only produced a new object reference when the underlying theme genuinely changed removed another layer of unnecessary re-renders I hadn't initially connected to the search box complaint at all.
A teammate suggested this was a sign we needed a dedicated state management library, but tracing the actual problem down to state placement and unstable references meant the fix lived entirely within React's own tools, and reaching for a bigger dependency would have added real complexity without addressing the actual root cause any more directly than the changes I'd already made.
Reviewing the Profiler output again after wrapping nearly every component in memo, several of them showed no meaningful render time saved at all, while the shallow prop comparison memo runs on every single render still cost something, just less than a full re-render. I ended up removing memo from the components whose props genuinely changed every render anyway, keeping it only on the handful where it was actually preventing real, avoidable work, a more deliberate, targeted use than my first instinct to wrap everything defensively.
The Profiler turned a vague "it feels laggy" complaint into a specific, visible problem within minutes, and the actual fix traced back to a genuinely common mistake, state living higher in the component tree than it needed to. I'd have spent hours guessing at memo and optimization tricks without first looking at exactly what was re-rendering and why.