Skip to content
GitHubXDiscord

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.

mitt / nanoeventsTriggery
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 payloadSchema['events']['foo'] enforced at compile time
Multiple handlers per eventMultiple reactors registering the same action; or multiple triggers listening to the same event
Manual debounce/throttle around emitactions.debounce(ms).foo?.(p)
One global emitterScoped via <TriggerScope id="…"> for multi-tenant trees
Before — mitt
import mitt from 'mitt';

type Events = { 'cart:added': { sku: string }; 'cart:removed': string };
export const bus = mitt<Events>();
Before — usage
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' });
After
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 });
  },
});
Reactors (in their own features)
// 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”
Before
bus.on('cart:added', e => {
  if (user.role === 'guest') guestAnalytics.track(e.sku);
  else                       userAnalytics.track(user.id, e.sku);
});
After
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.

Before
const debouncedFlush = throttle(() => bus.emit('cart:flush'), 300);
input.addEventListener('input', debouncedFlush);
After
// 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.

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.