Exploration · Acme web client

Three ways to implement debounced search

Prompt Show me three different ways to implement debounced search for the task filter input in our React codebase, with tradeoffs for each.

01Inline useEffect + setTimeout

Debounce logic lives directly inside the component that owns the input.

export function TaskSearch() {
  const [draft, setDraft] = useState('');
  const [query, setQuery] = useState('');

  useEffect(() => {
    const id = setTimeout(() => setQuery(draft), 300);
    return () => clearTimeout(id);
  }, [draft]);

  const { data } = useTasks({ search: query });

  return (
    <input
      value={draft}
      onChange={(e) => setDraft(e.target.value)}
      placeholder="Filter tasks…"
    />
  );
}
Pro
Con
Zero new abstractions to learn
Logic duplicated everywhere search exists
Easy to step through in devtools
Two pieces of state for one conceptual value
No dependency or bundle change
Delay constant is buried in component body
Bundle impact: +0 kb Testability: medium Reuse: low SSR safe: yes

02Custom useDebounce hook

Extract the timer into a shared hook under src/hooks/.

// src/hooks/useDebounce.ts
export function useDebounce<T>(value: T, ms = 300): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), ms);
    return () => clearTimeout(id);
  }, [value, ms]);
  return debounced;
}

// TaskSearch.tsx
const [draft, setDraft] = useState('');
const query = useDebounce(draft, 300);
const { data } = useTasks({ search: query });
Pro
Con
Single import reused across filter, command bar, board search
One more file to maintain and document
Trivial to unit test with fake timers
Generic T hides intent slightly
Delay is a visible, tunable argument
Still re-renders on every keystroke
Bundle impact: +0.2 kb Testability: high Reuse: high SSR safe: yes

03Tiny external library

Adopt use-debounce for both values and callbacks.

import { useDebouncedCallback }
  from 'use-debounce';

export function TaskSearch() {
  const [query, setQuery] = useState('');

  const onChange = useDebouncedCallback(
    (next: string) => setQuery(next),
    300,
    { leading: false, maxWait: 1000 },
  );

  const { data } = useTasks({ search: query });

  return (
    <input
      defaultValue=""
      onChange={(e) => onChange(e.target.value)}
    />
  );
}
Pro
Con
leading / trailing / maxWait handled for us
New runtime dependency to audit and update
Callback form skips intermediate re-renders
Uncontrolled input diverges from Acme form patterns
Well-tested edge cases (unmount, flush, cancel)
~1.4 kb gzipped for something we could own
Bundle impact: +1.4 kb Testability: high Reuse: high SSR safe: yes