Debounce requests to server

I recently got asked to implement a toy problem where there’s a autocompletion input box. As users enter a keyword in the box, the client would make a request to server to get back a list of suggestions to render.

Normally I would use deferred value to make the application responsive, like so

export default function App() {
  const [keyword, setKeyword] = useState("");
  const deferredKeyword = useDeferredValue(keyword);

  function handleKeywordChange(e: React.ChangeEvent<HTMLInputElement>) {
    setKeyword(e.target.value);
  }

  return (
    <div className="App">
      <label>
        Search: <input value={keyword} onChange={handleKeywordChange} />
      </label>
      <SearchResults query={deferredKeyword} />
    </div>
  );
}

Using deferred value allows React to rerender the component in background, and if there’s a new re-render, the previous render will be discarded.

The SearchResults component simply fetched suggestions on query update.

type Props = {
  query: string;
};

function SearchResults({ query }: Props): JSX.Element {
  const [suggestions, setSuggestions] = useState<string[]>([]);

  useEffect(() => {
    if (query.trim().length === 0) {
      return;
    }

    let ignored = false;

    fetchSuggestions(query).then((results) => {
      if (!ignored) {
        setSuggestions(results);
      }
    });
    return () => {
      ignored = true;
    };
  }, [query]);

  return (
    <ul>
      {suggestions.map((suggestion) => {
        return <li key={suggestion}>{suggestion}</li>;
      })}
    </ul>
  );
}

However I got asked to debounce the requests for 250ms, i.e. as user are typing we wait until the typing stops for at least 250ms before we sends a request to server. In terms of UX, this is actually worse than the solution above, because users have to wait at least 250ms after their last keystroke for the request to be sent, and another X ms for server to responds back. The only pros that I can think of is that this reduces the number of requests to server, so that maybe a win depends on how scalable the backend is.

But how do we actually do this ? The solution is actually quite simple: we create an additional state where the state update is debounced, like so

const [keyword, setKeyword] = useState("");
const [deferredKeyword, setDeferredKeyword] = useState("");

const debouncedSetDeferredKeyword = useMemo(
    () => debounce(setDeferredKeyword, 250), []);

function handleKeywordChange(e: React.ChangeEvent<HTMLInputElement>) {
    setKeyword(e.target.value);
    debouncedSetDeferredKeyword(e.target.value);
  }

There are 2 things to note in this solution:

  1. We still keep keyword state, so that the input box is updated instantly as user types
  2. We use useMemo instead of useCallback because we’re not memoizing a function definition. We’re memoizing the value returned by debounce.
  3. useMemo usage is important here. Without useMemo you get a new debounced instance each render, which has its own independent tracking of the last time it’s invoked.

With this solution deferredKeyword state update is debounced for 250ms, and when it updates, SearchResults re-renders and issues a new request to server.