From mitt / nanoevents
mitt and nanoevents are tiny untyped (or loosely typed) event emitters. They scale badly the moment the same event has more than one handler, or handlers need to read state, or you want to debounce. Triggery is what you’d build if you took those problems seriously.
Mental model mapping
Section titled “Mental model mapping”| mitt / nanoevents | Triggery |
|---|---|
emitter.on('foo', fn) | useAction(trigger, 'foo', fn) (or useEvent on the firing side) |
emitter.emit('foo', payload) | fireFoo(payload) from useEvent(trigger, 'foo') |
emitter.off('foo', fn) | Automatic — cleanup on component unmount |
| Untyped payload | Schema['events']['foo'] enforced at compile time |
| Multiple handlers per event | Multiple reactors registering the same action; or multiple triggers listening to the same event |
Manual debounce/throttle around emit | actions.debounce(ms).foo?.(p) |
| One global emitter | Scoped via <TriggerScope id="…"> for multi-tenant trees |
Pattern 1 — basic typed bus
Section titled “Pattern 1 — basic typed bus”import mitt from 'mitt';
type Events = { 'cart:added': { sku: string }; 'cart:removed': string };
export const bus = mitt<Events>();bus.on('cart:added', e => analytics.track('add', e.sku));
bus.on('cart:added', e => sidebar.flash(e.sku));
// somewhere else:
bus.emit('cart:added', { sku: 'A1' });export const cartTrigger = createTrigger<{
events: { 'cart:added': { sku: string }; 'cart:removed': string };
actions: { trackAddition: { sku: string }; flashSidebar: { sku: string } };
}>({
id: 'cart-added',
events: ['cart:added'],
required: [],
handler({ event, actions }) {
actions.trackAddition?.({ sku: event.payload.sku });
actions.flashSidebar?.({ sku: event.payload.sku });
},
});// analytics:
useAction(cartTrigger, 'trackAddition', ({ sku }) => analytics.track('add', sku));
// sidebar:
useAction(cartTrigger, 'flashSidebar', ({ sku }) => sidebar.flash(sku));
// producer:
const fireAdded = useEvent(cartTrigger, 'cart:added');
fireAdded({ sku: 'A1' });The trigger file lists the whole fan-out in one place — no hunting bus.on('cart:added' across the codebase.
Pattern 2 — fan-out with conditional routing
Section titled “Pattern 2 — fan-out with conditional routing”bus.on('cart:added', e => {
if (user.role === 'guest') guestAnalytics.track(e.sku);
else userAnalytics.track(user.id, e.sku);
});createTrigger<{
events: { 'cart:added': { sku: string } };
conditions: { user: { id: string; role: 'guest' | 'user' } };
actions: { trackGuest: string; trackUser: { userId: string; sku: string } };
}>({
id: 'cart-analytics',
events: ['cart:added'],
required: ['user'],
handler({ event, conditions, actions }) {
if (!conditions.user) return;
if (conditions.user.role === 'guest') {
actions.trackGuest?.(event.payload.sku);
} else {
actions.trackUser?.({ userId: conditions.user.id, sku: event.payload.sku });
}
},
});User identity is registered once as a condition by whichever feature owns auth; the cart trigger reads it lazily.
Pattern 3 — debounce around emit
Section titled “Pattern 3 — debounce around emit”const debouncedFlush = throttle(() => bus.emit('cart:flush'), 300);
input.addEventListener('input', debouncedFlush);// trigger:
createTrigger<{ events: { 'cart:flush': void }; actions: { saveCart: void } }>({
id: 'cart-flush',
events: ['cart:flush'],
required: [],
handler({ actions }) {
actions.debounce(300).saveCart?.();
},
});
// producer (no manual throttle):
const fireFlush = useEvent(cartTrigger, 'cart:flush');
input.addEventListener('input', () => fireFlush());The runtime owns the timer. Tests can drive time deterministically with the fakeScheduler.
Pattern 4 — scoping a bus per tenant
Section titled “Pattern 4 — scoping a bus per tenant”mitt has no concept of scope — for multi-tenant or micro-frontend setups you usually create one bus per tenant and pass it through context. Triggery handles this with <TriggerScope id="…">:
<TriggerScope id="tenant-a">
<CartFeature /> {/* useCondition/useAction registrations live in this scope */}
</TriggerScope>
<TriggerScope id="tenant-b">
<CartFeature /> {/* parallel, isolated */}
</TriggerScope>The trigger declaration adds scope: 'tenant-a' (or 'tenant-b') and the runtime keeps the registries separate. See Scopes.