Go REST

Debounce a search field properly

Avoid N requests per keystroke. Cancel in-flight requests with AbortController so the latest query always wins.

A search-as-you-type field is one of the most expensive UI patterns in cost-per-keystroke. Naive code fires a request on every key press. The user types "rama" and your server gets four requests in 100 ms. The responses arrive out of order, and the UI flashes through "results for r", "results for ra", "results for rama" before settling on... whichever response happened to land last. This recipe fixes it with debouncing, abort, and minimum-length filtering.

The naive version

One useEffect, fires on every change ofquery. Good intuition, broken in practice.

function Search() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query) return setResults([]);
    fetch(`/public/v2/users?name=${encodeURIComponent(query)}`)
      .then(r => r.json())
      .then(setResults);
  }, [query]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

Three problems:

The fixed version

Three improvements:

import { useEffect, useState } from "react";

function useDebouncedValue(value, ms = 250) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const t = setTimeout(() => setDebounced(value), ms);
    return () => clearTimeout(t);
  }, [value, ms]);
  return debounced;
}

function Search() {
  const [query, setQuery]     = useState("");
  const [results, setResults] = useState([]);
  const debounced = useDebouncedValue(query, 250);

  useEffect(() => {
    if (debounced.length < 2) return setResults([]);
    const ac = new AbortController();
    fetch(`/public/v2/users?name=${encodeURIComponent(debounced)}`,
          { signal: ac.signal })
      .then(r => r.json())
      .then(setResults)
      .catch(err => { if (err.name !== "AbortError") throw err; });
    return () => ac.abort();
  }, [debounced]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

The debounce hook

Pulled out as a hook so you can reuse it for any debounced state, not just search:

const debounced = useDebouncedValue(query, 250);

The hook delays thevalue by N ms. Each new value resets the timer. The cleanupclearTimeout is what makes it work; it cancels the previous timer when the user types again.

Why AbortController matters

Even with debouncing, two requests can be in flight at once if the user types, pauses 300 ms, then types more. The first request fires, then the second cancels-and-fires. Without abort, both responses arrive; whichever onesetResults is called with last wins, which is not necessarily the most recent query.

Abort fixes it. The cleanup function inuseEffect callsac.abort(), which causesfetch to reject withAbortError. We filter that out in the catch so it does not become a visible error.

Minimum length

Searching for "a" returns half the database. It is rarely useful. Skip the request below 2 characters, ideally 3:

if (debounced.length < 2) return setResults([]);

Tune the cutoff to your domain. For names, 2 is usually fine; for emails, 3 is better; for free-text descriptions, 4 or 5.

Loading state

Add a busy flag so the user knows a search is in flight:

const [busy, setBusy] = useState(false);
// ...inside the effect:
setBusy(true);
fetch(...).finally(() => setBusy(false));
// ...in the JSX:
{busy && <Spinner />}

One detail: the busy flag should setoutside the abort cleanup so an aborted request does not leave the spinner spinning. The.finally on the fetch handles that.

Server-side empty-state for short queries

If the user searches for "a" and gets no results, do not show "no users found"; it is misleading. Show a hint instead: "Type at least 2 characters". Same idea for empty input: show recent searches, suggested filters, or just nothing. Empty-state-as-zero-results is one of the worst UI choices.

What about server-side debouncing?

Some teams handle the burst by debouncing on the server (queue the request, drop earlier ones). It works, but it does not save the network round-trips; the client still sent N requests. Client-side debouncing is strictly better.

With Go REST

Filter on thename query parameter; substring match. The full URL pattern is:

/public/v2/users?name=avani&status=active

Combine with status, gender, or email for richer filters. Substring matching on multiple fields means you can build a "type-anywhere" search by passing the same value to several?name=&email= parameters.

Common mistakes

More recipes

Keep building

All recipes Integration guides