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.
Scenario
Section titled “Scenario”- Every keystroke fires
search-querywith the current input value. - The trigger debounces the network call by 300 ms — bursts of typing collapse into one request.
take-latestconcurrency aborts any in-flight request the moment a newer keystroke arrives.- The trigger uses
ctx.signalso the actualfetchis cancelled at the network level, not just ignored on resolve. - A reactor writes the results into a store the dropdown subscribes to.
File layout
Section titled “File layout”Directorysrc/
Directorytriggers/
- search.trigger.ts debounce + take-latest + fetch
Directoryfeatures/
Directorysearch/
- SearchInput.tsx producer (
useEvent) - SuggestionDropdown.tsx reactor + UI
- SearchInput.tsx producer (
Directorystores/
- suggestions.ts plain store
1. The trigger
Section titled “1. The trigger”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);
}
},
});2. The producer
Section titled “2. The producer”The input fires one event per keystroke. That’s the whole component.
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.
3. The reactor
Section titled “3. The reactor”A small store keeps the latest result set. The reactor writes into it; the dropdown subscribes and renders.
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 }),
}));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.
4. Wire it up
Section titled “4. Wire it up”import { SearchInput } from './SearchInput';
import { SuggestionDropdown } from './SuggestionDropdown';
export function Search() {
return (
<div className="search">
<SearchInput />
<SuggestionDropdown />
</div>
);
}Test it
Section titled “Test it”A focused vitest — no React, no DOM, no real network.
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([]);
});
});What this buys you
Section titled “What this buys you”| Before (useEffect + AbortController) | With Triggery | |
|---|---|---|
| Lines in the input component | 25-40 (timer + ref + abort + cleanup) | 1 useEvent call |
| Late-response bug | Always lurking | Impossible — take-latest aborts |
| Debounce timer leaks on unmount | Caller’s problem | Runtime owns it |
| Switching to throttle / leading-edge | Rewrite the effect | One line in the trigger |
| Testable without DOM | No (timers, refs) | Yes |