Skip to content
GitHubXDiscord

Debounced search

The classic autocomplete: user types into a search field, the app fetches suggestions from an API, late responses don’t overwrite newer ones, the network isn’t hammered. Without Triggery this is a 40-line useEffect riddled with refs, race-condition bookkeeping, and an AbortController you carry around by hand. With Triggery the input fires one event per keystroke and the trigger handles concurrency, cancellation and debouncing declaratively.

Open in StackBlitz Open example on GitHub
  1. Every keystroke fires search-query with the current input value.
  2. The trigger debounces the network call by 300 ms — bursts of typing collapse into one request.
  3. take-latest concurrency aborts any in-flight request the moment a newer keystroke arrives.
  4. The trigger uses ctx.signal so the actual fetch is cancelled at the network level, not just ignored on resolve.
  5. A reactor writes the results into a store the dropdown subscribes to.
  • Directorysrc/
    • Directorytriggers/
      • search.trigger.ts debounce + take-latest + fetch
    • Directoryfeatures/
      • Directorysearch/
        • SearchInput.tsx producer (useEvent)
        • SuggestionDropdown.tsx reactor + UI
    • Directorystores/
      • suggestions.ts plain store
src/triggers/search.trigger.ts
import { createTrigger } from '@triggery/core';

type Suggestion = { id: string; label: string };

export const searchTrigger = createTrigger<{
  events: {
    'search-query': string;
  };
  actions: {
    setSuggestions: readonly Suggestion[];
    setError:       string;
    setLoading:     boolean;
  };
}>({
  id: 'search',
  events: ['search-query'],
  // Newer keystrokes abort older fetches via ctx.signal.
  concurrency: 'take-latest',
  async handler({ event, actions, signal }) {
    const q = event.payload.trim();

    if (q.length < 2) {
      actions.setSuggestions?.([]);
      actions.setLoading?.(false);
      return;
    }

    // Debounce burst typing into one trip to the network.
    actions.debounce(300).setLoading?.(true);

    try {
      const url = `/api/search?q=${encodeURIComponent(q)}`;
      const res = await fetch(url, { signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = (await res.json()) as readonly Suggestion[];

      actions.setSuggestions?.(data);
      actions.setError?.('');
    } catch (err) {
      // `take-latest` aborts the previous run via `signal` — swallow that
      // specific case so we don't render a phantom error from cancellation.
      if ((err as Error).name === 'AbortError') return;
      actions.setError?.((err as Error).message);
    } finally {
      actions.setLoading?.(false);
    }
  },
});

The input fires one event per keystroke. That’s the whole component.

src/features/search/SearchInput.tsx
import { useEvent } from '@triggery/react';
import { useState } from 'react';
import { searchTrigger } from '../../triggers/search.trigger';

export function SearchInput() {
  const [value, setValue] = useState('');
  const fireQuery = useEvent(searchTrigger, 'search-query');

  return (
    <input
      type="search"
      placeholder="Type to search…"
      value={value}
      onChange={e => {
        const next = e.target.value;
        setValue(next);
        fireQuery(next);
      }}
    />
  );
}

No useEffect. No AbortController. No setTimeout. No tracking of “the latest request id”. The input does not even know that fetch happens.

A small store keeps the latest result set. The reactor writes into it; the dropdown subscribes and renders.

src/stores/suggestions.ts
import { create } from 'zustand';

type Suggestion = { id: string; label: string };

type State = {
  suggestions: readonly Suggestion[];
  loading: boolean;
  error: string;
  setSuggestions: (next: readonly Suggestion[]) => void;
  setLoading:     (next: boolean) => void;
  setError:       (next: string) => void;
};

export const useSuggestionsStore = create<State>(set => ({
  suggestions: [],
  loading: false,
  error: '',
  setSuggestions: suggestions => set({ suggestions }),
  setLoading:     loading => set({ loading }),
  setError:       error => set({ error }),
}));
src/features/search/SuggestionDropdown.tsx
import { useAction } from '@triggery/react';
import { searchTrigger } from '../../triggers/search.trigger';
import { useSuggestionsStore } from '../../stores/suggestions';

export function SuggestionDropdown() {
  const { suggestions, loading, error, setSuggestions, setLoading, setError } =
    useSuggestionsStore();

  useAction(searchTrigger, 'setSuggestions', setSuggestions);
  useAction(searchTrigger, 'setLoading',     setLoading);
  useAction(searchTrigger, 'setError',       setError);

  if (error) return <p role="alert">{error}</p>;
  if (loading && suggestions.length === 0) return <p>Searching…</p>;
  if (suggestions.length === 0) return null;

  return (
    <ul role="listbox">
      {suggestions.map(s => (
        <li key={s.id} role="option">{s.label}</li>
      ))}
    </ul>
  );
}

The dropdown is a normal React component. Triggery only owns the behaviour; the UI is yours.

src/features/search/Search.tsx
import { SearchInput } from './SearchInput';
import { SuggestionDropdown } from './SuggestionDropdown';

export function Search() {
  return (
    <div className="search">
      <SearchInput />
      <SuggestionDropdown />
    </div>
  );
}

A focused vitest — no React, no DOM, no real network.

src/triggers/search.trigger.test.ts
import { createTestRuntime, mockAction } from '@triggery/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { searchTrigger } from './search.trigger';

describe('search', () => {
  let originalFetch: typeof fetch;

  beforeEach(() => {
    originalFetch = globalThis.fetch;
  });

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it('aborts the previous fetch on a new keystroke', async () => {
    const rt = createTestRuntime({ triggers: [searchTrigger] });
    const setSuggestions = vi.fn();
    mockAction(rt, searchTrigger, 'setSuggestions', setSuggestions);
    mockAction(rt, searchTrigger, 'setLoading', () => {});

    const aborted: string[] = [];
    globalThis.fetch = vi.fn((url, init) =>
      new Promise<Response>((resolve, reject) => {
        init?.signal?.addEventListener('abort', () => {
          aborted.push(String(url));
          reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
        });
        setTimeout(() => resolve(new Response('[]', { status: 200 })), 50);
      }),
    ) as typeof fetch;

    rt.fire('search-query', 'tri');
    await new Promise(r => setTimeout(r, 5));
    rt.fire('search-query', 'trig');

    await new Promise(r => setTimeout(r, 80));

    expect(aborted.some(u => u.includes('q=tri&') || u.endsWith('q=tri'))).toBe(true);
    expect(setSuggestions).toHaveBeenCalled();
  });

  it('does not fetch for queries shorter than 2 chars', async () => {
    const rt = createTestRuntime({ triggers: [searchTrigger] });
    const setSuggestions = vi.fn();
    globalThis.fetch = vi.fn() as unknown as typeof fetch;
    mockAction(rt, searchTrigger, 'setSuggestions', setSuggestions);

    await rt.fire('search-query', 'a');

    expect(globalThis.fetch).not.toHaveBeenCalled();
    expect(setSuggestions).toHaveBeenCalledWith([]);
  });
});
Before (useEffect + AbortController)With Triggery
Lines in the input component25-40 (timer + ref + abort + cleanup)1 useEvent call
Late-response bugAlways lurkingImpossible — take-latest aborts
Debounce timer leaks on unmountCaller’s problemRuntime owns it
Switching to throttle / leading-edgeRewrite the effectOne line in the trigger
Testable without DOMNo (timers, refs)Yes