## 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 Birchline 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

## Recommendation

Go with approach 02, the custom `useDebounce` hook. Birchline already has three places that hand-roll the inline pattern (task filter, command palette, member picker), so extracting one shared hook removes duplication without taking on a new dependency.

Revisit approach 03 only if we later need `maxWait` or `flush()` semantics — the library earns its bundle cost once the requirements outgrow a ten-line hook.