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
Every keystroke fires search-query with the current input value.
The trigger debounces the network call by 300 ms — bursts of typing collapse into one request.
take-latest concurrency aborts any in-flight request the moment a newer keystroke arrives.
The trigger uses ctx.signal so the actual fetch is cancelled at the network level, not just ignored on resolve.
A reactor writes the results into a store the dropdown subscribes to.
Directory src/
Directory triggers/
Directory features/
Directory search/
SearchInput.tsx SuggestionDropdown.tsx Directory stores/
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 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