Fixing a laggy search filter with useDeferredValue

Typing into the filter above a long table felt like typing over a bad SSH connection. React 18 has a hook for exactly this and the fix was one line. Well, two.

There is a page at work with a table of a couple thousand rows and a filter box above it. The rows are already in the browser, so filtering them is instant. Rendering the result is not. You type a letter, you wait, the letter appears together with the new table. Type three letters quickly and it feels like typing over a bad SSH connection.

The usual answer is a debounce, and that's what was there. It made the lag predictable rather than gone.

Why the input lags

A keystroke sets query. React re-renders the page, the page filters the rows and renders the table, and until that render is done the browser can't paint anything, including the letter in the input. The input is cheap and the table is expensive, but they live in the same render, so the cheap one waits for the expensive one.

The fixes I had tried before, in order of how much they helped:

  • Debounce the query. A fixed delay, the same on a fast desktop and a slow phone. Typing feels better, the table feels worse, and it still blocks when it finally runs. Good for not hammering an API, which was not the problem here.
  • useMemo the filtering. Helped nothing, because the React DevTools Profiler showed the time was in rendering rows, not in picking them.
  • Virtualise the table. The right answer for a really long list, and the one I didn't want this week, because the table component is shared and I would be changing it for one page.

useDeferredValue

React 18 added a hook for a value that is allowed to lag behind:

jsx
// src/pages/Members.jsx
import { useState, useDeferredValue } from 'react';
import { MemberTable } from './MemberTable';

export function Members({ members }) {
  const [query, setQuery] = useState('');
  // follows query, but React is allowed to update it later, when it has time
  const deferredQuery = useDeferredValue(query);

  return (
    <>
      <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter" />
      <MemberTable members={members} query={deferredQuery} />
    </>
  );
}

On a keystroke React renders the page right away with the new query and the old deferredQuery. The input updates, the table gets the same props it had, and the browser paints. Then React starts a second render in the background with the new deferred value. If another keystroke lands before it finishes, that render is thrown away and a new one starts. The table catches up when you stop typing, or sooner when the machine is fast enough to keep up.

That is the one line. The second one is that the table must skip the first render, otherwise it re-renders with the old query for no reason and you've gained nothing:

jsx
// src/pages/MemberTable.jsx
import { memo } from 'react';

// same members, same query, same table: skip the render
export const MemberTable = memo(function MemberTable({ members, query }) {
  const needle = query.toLowerCase();
  const visible = members.filter((member) => member.name.toLowerCase().includes(needle));

  return (
    <table>
      <tbody>
        {visible.map((member) => (
          <tr key={member.id}>
            <td>{member.name}</td>
            <td>{member.email}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
});

memo compares props with ===, so members has to be the same array between renders. Mine comes from props, which come from a query cache. If yours is built with a .filter() or .map() in the parent on every render, that's what you fix first.

Telling the user it's behind

While the deferred value is behind, the table on screen belongs to a query the user no longer sees in the box. A small visual cue helps:

jsx
const isStale = query !== deferredQuery;

<div style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 0.2s' }}>
  <MemberTable members={members} query={deferredQuery} />
</div>

Dimming is enough. A spinner would be too much for something that lasts a fraction of a second.

useTransition, the sibling

If you own the state update, useTransition does the same thing from the other end:

jsx
// src/pages/Members.jsx, the useTransition version
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState('');
const [tableQuery, setTableQuery] = useState('');

function handleChange(event) {
  // urgent, the input must show the letter now
  setQuery(event.target.value);
  // can wait, React may interrupt this one
  startTransition(() => {
    setTableQuery(event.target.value);
  });
}

Two states instead of one, and isPending for free. The way I decide: useDeferredValue when the value arrives from somewhere I don't control, props, a hook, the URL. useTransition when I'm the one calling the setter and want the pending flag. Both need the createRoot root from React 18, which is the one line I changed in the spring. With the old ReactDOM.render they fall back to doing nothing.

What it does not do

Deferring doesn't make the work any smaller: the table render takes exactly as long as before. On a slow phone it still takes that long, but typing stays smooth and you can change your mind halfway. If the rows are in the tens of thousands, virtualise. The deferred value and a virtualised table together is the good version.

It also does nothing for network requests. If every keystroke calls an API, you still want a debounce, and probably both.

On this page there was no API to protect, so one hook and one memo later the debounce went in the bin. My favourite kind of fix is the one that ends with a deletion.

Read more here: