DEV Community

Cover image for My React Search Box Froze on Every Keystroke. I Fixed It by Stealing Python's #1 Scope Rule.
S M Tahosin
S M Tahosin Subscriber

Posted on

My React Search Box Froze on Every Keystroke. I Fixed It by Stealing Python's #1 Scope Rule.

One search box made my whole React dashboard feel broken.

Every keypress landed late. Almost 300ms late.

My first instinct was the same one a lot of React developers have: memoize everything.

useMemo?

useCallback?

Wrap half the app in React.memo and hope the smoke clears?

That was my first instinct. It was also the wrong one.

The app was not huge. It was a side-project dashboard with roughly 150 components: a sidebar, a header, a data table, a few charts, filters, settings panels, the usual little pieces that pile up faster than you expect.

Then I typed into the search box.

The input was almost 300ms behind my actual typing. Not "maybe I can feel it" slow. Properly annoying slow. The kind where you press backspace twice, wait, and then wonder if your laptop is having a private crisis.

React DevTools made it obvious. One keystroke was lighting up the whole screen.

The search box changed.

The table changed.

Also the header rendered again.

The sidebar rendered again.

The charts rendered again.

Even the footer showed up in the profiler like it had important search-related business to attend to.

It did not.

The mistake was boring

Here is a smaller version of what I had:

import { useState } from "react";

export default function Dashboard() {
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <div className="layout">
      <Header />
      <Sidebar />
      <main>
        <SearchBar value={searchQuery} onChange={setSearchQuery} />
        <DataGrid query={searchQuery} />
        <HeavyCharts />
        <Footer />
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

At first glance this does not look terrible. The search query belongs to the dashboard page, right?

That was the trap.

The state lived in Dashboard, so every search change updated Dashboard. When a component's state changes, React renders that component again and then renders through its children unless something bails out.

So every keystroke asked the whole dashboard to participate.

That is not React being weird. That is me putting a noisy piece of state in a place with a huge audience.

The profiler before the fix looked like this:

React Profiler before the fix, with the whole dashboard re-rendering and 142.7ms total render time

Per keystroke, this render path was taking 142.7ms.

For a smooth 60 FPS interaction, you get around 16ms per frame. My search box was spending almost nine frames just rendering. No mystery there. It felt slow because it was slow.

Python had already taught me the rule

The funny part is that I already knew the principle. I just did not recognize it in React clothing.

In Python, you learn pretty quickly not to throw state into a wider scope than it needs.

If a variable is only used inside one function, keep it inside that function. If you make it module-level because "maybe something else will need it later", you usually buy yourself confusion. More places can touch it. More code has to care about it. Debugging gets louder.

React state has the same smell.

This is not a perfect one-to-one comparison. useState inside Dashboard is not literally a Python global. But it behaved like a value with too much reach. A tiny search string had enough placement power to wake up the entire page.

So instead of asking, "How do I memoize all these components?"

I asked a simpler question:

Who actually needs searchQuery?

The answer was embarrassingly small.

SearchBar needs it.

DataGrid needs it.

That is basically it.

The fix was one small wrapper

I moved the state down into a component that only contains the search input and the table.

import { useState } from "react";

function SearchableDataGrid() {
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <>
      <SearchBar value={searchQuery} onChange={setSearchQuery} />
      <DataGrid query={searchQuery} />
    </>
  );
}

export default function Dashboard() {
  return (
    <div className="layout">
      <Header />
      <Sidebar />
      <main>
        <SearchableDataGrid />
        <HeavyCharts />
        <Footer />
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

That was the whole change.

Dashboard went back to being layout.

SearchableDataGrid became the owner of the noisy state.

Now a keystroke renders SearchableDataGrid, SearchBar, and DataGrid. It does not ask Header, Sidebar, HeavyCharts, or Footer to do anything.

After the change, the profiler looked like this:

React Profiler after the fix, with only SearchableDataGrid re-rendering and 4.1ms total render time

Render time dropped from 142.7ms to 4.1ms per keystroke.

That number is from my app on my machine, so do not read it as a magic promise. The important part is the shape of the fix: the render work stopped spreading across the page.

Here is the difference visually:

Component tree before and after the fix, showing the render blast radius shrinking from the whole dashboard to the search area

And here is the isolated version:

State colocation diagram showing search state living inside SearchableDataGrid

The rule I took from it:

If a piece of state changes often, do not put it higher than the components that actually need it.

Why memoization was the wrong first move

There is nothing wrong with React.memo.

There is nothing wrong with useMemo.

There is nothing wrong with useCallback.

But in this case I was reaching for them too early.

Memoization can help a component skip work when its inputs have not changed. It can be great for expensive children with stable props. But it also adds another thing your future self has to understand.

The cleaner fix was to stop triggering those components in the first place.

I now try to debug React performance in this order:

  1. Did I put state too high?
  2. Is the expensive work happening during render?
  3. Can the expensive work be delayed or split?
  4. Is this finally a real memoization problem?

That order saves me from decorating bad structure with clever hooks.

When this trick is not enough

State colocation fixed my issue because the wasted work was outside the table.

If your table itself is expensive, this will not magically save you.

For example, if DataGrid filters 10,000 rows on every keypress, moving the state down still leaves you with a heavy table render. In that case, useDeferredValue is often a better next step:

import { useDeferredValue, useState } from "react";

function SearchableDataGrid() {
  const [searchQuery, setSearchQuery] = useState("");
  const deferredQuery = useDeferredValue(searchQuery);

  return (
    <>
      <SearchBar value={searchQuery} onChange={setSearchQuery} />
      <DataGrid query={deferredQuery} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

That keeps the input responsive while React updates the heavier result view with a lower priority.

There are also cases where the state really does need to live higher.

If the header needs to show "23 matching results", and the sidebar needs to sync with the same query, pushing the state all the way down might make the data flow awkward. At that point I would consider lifting it back up, using context for a small shared area, or using a tiny external store if the page is getting messy.

And yes, sometimes React.memo is exactly the right tool:

import React from "react";

const HeavyCharts = React.memo(function HeavyCharts() {
  // Expensive chart rendering that does not depend on searchQuery.
  return <Charts />;
});
Enter fullscreen mode Exit fullscreen mode

I just do not want it to be my first reflex anymore.

The tiny checklist I use now

When a React interaction feels slow, I open the Profiler before changing code.

I record the interaction once.

Then I ask:

  • What changed?
  • Which components rendered?
  • Which of those renders were actually needed?
  • Is the slow part extra rendering, or expensive work inside the component that really did need to render?

If the whole page lights up after one keypress, I look for state sitting too high in the tree.

If only the right component lights up but it still takes 100ms, I look inside that component.

That one distinction saves a lot of random hook experiments.

The part that annoyed me

The fix was not advanced.

It was not a new library.

It was not a clever hook.

It was a boring scoping mistake.

I had treated a search string like page-level state because it was convenient at the time. React did exactly what I asked it to do, and then I spent an hour blaming rendering.

The Python version of the lesson would be:

If a variable belongs in a function, do not put it in the module.

The React version:

If state belongs to two components, do not make the whole page carry it.

That is the rule I am keeping.

Have you had one of these "wait, the fix was that small?" performance bugs in React or another framework? I am especially curious about the ones where the profiler made you feel personally attacked.

Top comments (0)