use-typed-event
v0.1.0
Published
Minimal TS-native typed event emitter with first-class React hooks. Typed wildcard, void events, waitFor, zero dependencies.
Maintainers
Readme
use-typed-event
Minimal TS-native typed event emitter with first-class React hooks. End-to-end typed event map, correctly-typed wildcard, void events,
waitFor, zero dependencies: 750 B gzipped core, 1,072 B gzipped React entry.
Why
The existing options make you pick between typing, size, and React integration:
- mitt and nanoevents are wonderfully tiny, but ship no React glue:
wiring a component to an event means a manual
useState+useEffectin every component. And mitt's wildcard handler types the payload as a union of all payloads without narrowing by name.
use-typed-event gives you all three: a single EventMap typed end-to-end
(emit, on, once, off, waitFor, and the wildcard all check against the same
map), a wildcard whose payload narrows when you narrow the name, void events
that take no payload argument, promise-based waitFor with AbortSignal support,
and two zero-wiring React hooks (useEvent, useEventState), all with zero runtime
dependencies.
Installation
pnpm add use-typed-event
# or
npm install use-typed-eventRequires Node ≥ 20. Ships dual ESM + CJS builds with TypeScript types and has zero
runtime dependencies. react (^18 or ^19) is an optional peer dependency:
the core use-typed-event entry works without React; only the
use-typed-event/react entry needs it.
Usage
Core emitter
import { createEmitter } from "use-typed-event"
type Events = {
"cart.add": { sku: string; qty: number }
"user.login": { id: string }
"app.ready": void
}
const emitter = createEmitter<Events>()
const unsubscribe = emitter.on("cart.add", ({ sku, qty }) => {
console.log(`added ${qty} × ${sku}`)
})
emitter.once("user.login", ({ id }) => console.log(`first login: ${id}`))
emitter.emit("cart.add", { sku: "A-1", qty: 2 })
emitter.emit("app.ready") // void event, no payload argument
unsubscribe() // idempotent, safe to call more than once
emitter.off("user.login") // omit the handler to remove every handler for a nameEvery subscription (on, once, onAny) returns an idempotent unsubscribe
function. off(name, handler) removes the first registration of that handler,
including once registrations, which are matched by the original handler
reference you passed in.
Typed wildcard with narrowing
onAny receives (name, payload) for every emit; narrowing on name narrows
payload to the mapped type:
emitter.onAny((name, payload) => {
if (name === "cart.add") {
console.log(payload.qty) // payload is { sku: string; qty: number } here
}
})Named handlers are delivered first, then wildcard handlers.
waitFor with AbortSignal
waitFor(name) resolves with the next payload of name. Pass a signal to stop
waiting; the promise rejects with signal.reason:
const { id } = await emitter.waitFor("user.login")
// give up after 5 seconds
await emitter.waitFor("app.ready", { signal: AbortSignal.timeout(5_000) })An already-aborted signal rejects immediately without subscribing.
Typed selectors
Event names are just strings, so every API above works with a plain string key.
But magic strings give up autocomplete and break silently on rename. Pass
createSelectors<E>() a typed accessor instead: each dot-access maps to the
"." separator in the key, so events.cart.add === "cart.add".
import { createEmitter, createSelectors } from "use-typed-event"
type Events = {
"cart.add": { sku: string; qty: number }
"user.login": { id: string }
"app.ready": void
}
const emitter = createEmitter<Events>()
const events = createSelectors<Events>()
emitter.emit(events.cart.add, { sku: "A-1", qty: 2 }) // same as emit("cart.add", ...)
emitter.on(events.user.login, ({ id }) => console.log(id))
await emitter.waitFor(events.app.ready)Selectors resolve to the exact string literal (events.cart.add has type
"cart.add"), so payload inference is identical to the string form: you get
autocomplete while typing the path and a compile error if a key is renamed or
removed. Strings and selectors are fully interchangeable and share state:
emit(events.cart.add, p) reaches on("cart.add", …) and vice-versa, because
both normalize to the same key.
Leaf-and-prefix collisions
When a key is both a leaf and a namespace prefix (an event map containing
both "cart" and "cart.add"), events.cart can't be both a string leaf and a
navigable object. The selector keeps navigating deeper and exposes the leaf
through the reserved $ accessor:
type Events = {
cart: { total: number }
"cart.add": { sku: string }
}
const events = createSelectors<Events>()
emitter.emit(events.cart.$, { total: 5 }) // the "cart" leaf; events.cart.$ is typed "cart"
emitter.emit(events.cart.add, { sku: "A-1" }) // still navigates to "cart.add"$ is fully type-checked (events.cart.$ has type "cart" with the right
payload) and works at any depth (events.cart.item.$). It is a reserved
segment, so avoid an event key with a literal $ segment. If you'd rather not
use it, the plain string form (emit("cart", …)) always works, or you can avoid
the collision by namespacing the leaf (e.g. "cart.total" instead of "cart").
React hooks
The use-typed-event/react entry gives you a bus factory whose hooks are already
bound to the emitter: no provider, no context, no wiring:
import { createEventBus } from "use-typed-event/react"
type Events = {
"cart.add": { sku: string; qty: number }
"counter.changed": number
}
const bus = createEventBus<Events>()
const CartBadge = () => {
const [lastAdd, setLastAdd] = bus.useEventState("cart.add")
return (
<button onClick={() => setLastAdd({ sku: "A-1", qty: 1 })}>
{lastAdd ? lastAdd.sku : "empty"}
</button>
)
}
const LoginToast = () => {
bus.useEvent("cart.add", ({ sku }) => showToast(`added ${sku}`))
return null
}
// emit from anywhere: event handlers, effects, non-React code
bus.emit("cart.add", { sku: "A-1", qty: 2 })The bus also exposes typed selectors as bus.events, so you
can drop magic strings on the React side too. Selector and string forms address
the same event and share state:
bus.emit(bus.events.cart.add, { sku: "A-1", qty: 2 })
const [lastAdd] = bus.useEventState(bus.events.cart.add)
bus.useEvent(bus.events.cart.add, ({ sku }) => showToast(`added ${sku}`))useEventState(name)returns a[value, setValue]tuple.valueis the last emitted payload as React state (undefineduntil the first emit). Payloads emitted before the component mounts are still visible; the bus records the last payload of every event.useEventState(name, initialValue)typesvalueasE[K](noundefined). The initial value is pinned on the first render; later changes to it are ignored.setValueemits on the bus: it isemit(name, ...)in disguise, not local state, so every subscriber of the event updates, not just the calling component. Its argument follows the same rules asemit: none forvoidevents, optional whenundefinedis allowed, required otherwise. It is referentially stable across renders for a givenname.
const [count, setCount] = bus.useEventState("counter.changed", 0) // count: number, not number | undefined
setCount(count + 1) // === bus.emit("counter.changed", count + 1)useEvent(name, handler)subscribes for the component's lifetime. The latesthandleris always invoked; changing its identity never resubscribes, so you can pass inline closures freely. Changingnameresubscribes.
Binding hooks to a shared core emitter
If an emitter already exists (e.g. shared with non-React code), bind hooks to it
with createEventHooks:
import { createEmitter } from "use-typed-event"
import { createEventHooks } from "use-typed-event/react"
const emitter = createEmitter<Events>()
const { useEvent, useEventState } = createEventHooks(emitter)Note: createEventHooks installs a wildcard recorder on the emitter so
useEventState can see payloads emitted before any hook mounts. Calling
emitter.clear() directly also removes that recorder, so prefer createEventBus,
whose clear keeps recording intact (see below).
Semantics you should know
Mutation during emit
Delivery for an in-flight emit is stable: handlers added during an emit are not
called for that emit, and handlers removed during an emit are still called
for it (removal is copy-on-write). Both take effect from the next emit. A once
handler is unsubscribed before it runs, so it fires exactly once even if its own
emit re-enters.
Error propagation
Handlers are not wrapped in try/catch: a throwing handler propagates the
error to the emit caller and stops delivery to the remaining handlers for that
emit. If you need isolation, catch inside the handler.
clear() semantics
bus.clear() removes every handler (including subscriptions of currently
mounted hooks) and forgets all recorded payloads, then reinstalls the payload
recorder so subsequent emits keep being recorded. Mounted useEventState
components are not notified: they keep showing their current value and stop
receiving emits until they resubscribe (a name change or a remount). Components
mounted after clear() see the initial value again.
Last-payload memory (dynamic event names)
The bus records the last payload of every emitted event name for its
lifetime. With a fixed, finite event map this is a few map entries; but if you
emit under high-cardinality or dynamically generated names, the map grows without
bound until clear() is called.
Removed-handler tombstones (dynamic event names)
The core emitter has a related property: removing a handler tombstones the
event name's map slot instead of deleting the key; only off(name) and
clear() truly delete. With a fixed event map this is negligible, but
emitting or subscribing under unbounded unique names retains a map key per
name for the emitter's lifetime.
SSR
useEventState is built on useSyncExternalStore and provides a
getServerSnapshot that returns the initial value (or undefined without one),
so server rendering works out of the box and hydrates consistently.
Same-value emits don't re-render
useEventState re-renders only when the payload changes by Object.is; emitting
the same value twice does not re-render. If you need to react to every
occurrence of an event (e.g. emit("save") twice in a row), use useEvent,
which invokes the handler on every emit regardless of the payload.
Known type limitation: unions mixing void and payload events
When the event name is a union mixing a void event with payload-carrying ones
(e.g. emit(cond ? "app.ready" : "user.login")), the payload type becomes a union
containing void, which turns the payload argument optional: the call compiles
with zero arguments even though "user.login" alone requires one. This
non-distributive form of PayloadArgs is a deliberate ergonomics tradeoff;
call emit with a literal name to keep full checking.
API reference
use-typed-event (core)
| Export | Signature | Notes |
| --- | --- | --- |
| createEmitter | createEmitter<E extends EventMap>(): Emitter<E> | Creates a typed emitter for the event map E. |
| createSelectors | createSelectors<E extends EventMap>(): EventSelectors<E> | Typed accessor whose dot-path resolves to the event key (events.cart.add === "cart.add"); interchangeable with string keys. |
| Emitter.on | on(name, handler): Unsubscribe | Registers a handler; returns an idempotent unsubscribe. |
| Emitter.once | once(name, handler): Unsubscribe | Invoked at most once; unsubscribed before it runs. |
| Emitter.off | off(name, handler?): void | Removes the first registration of handler (matches once by original reference), or every handler for name when omitted. |
| Emitter.emit | emit(name, ...payload): void | Payload argument is omitted for void events, optional when undefined is allowed, required otherwise. Named handlers run before wildcard handlers. |
| Emitter.onAny | onAny(handler): Unsubscribe | Wildcard handler (name, payload); narrowing name narrows payload. |
| Emitter.waitFor | waitFor(name, options?): Promise<E[K]> | Resolves with the next payload; options.signal rejects with signal.reason on abort. |
| Emitter.clear | clear(): void | Removes every named and wildcard handler. |
| Types | EventMap, Emitter, EventHandler, AnyHandler, EventTuple, PayloadArgs, Unsubscribe, WaitForOptions, EventSelectors | |
use-typed-event/react
| Export | Signature | Notes |
| --- | --- | --- |
| createEventBus | createEventBus<E extends EventMap>(): EventBus<E> | Fresh emitter + bound hooks in one object; its clear() also forgets recorded payloads and keeps recording working. |
| bus.events | EventSelectors<E> | Typed selectors bound to the bus (bus.events.cart.add === "cart.add"); usable anywhere a string key is, including useEvent / useEventState. |
| createEventHooks | createEventHooks<E>(emitter: Emitter<E>): EventHooks<E> | Binds useEvent / useEventState to an existing emitter (installs a payload recorder). |
| useEvent | useEvent(name, handler): void | Subscribes for the component's lifetime; always calls the latest handler, never resubscribes on handler identity change. |
| useEventState | useEventState(name): [E[K] \| undefined, setter]useEventState(name, initialValue): [E[K], setter] | [value, setValue] tuple: last emitted payload as state (Object.is-deduplicated, SSR-safe) plus a stable setter that emits on the bus, typed setter: (...args: PayloadArgs<E[K]>) => void. |
| Types | EventBus, EventHooks, UseEvent, UseEventState, EventSelectors (+ re-exports everything from the core entry) | |
Benchmark: emitter throughput
All three comparison suites below can also be viewed as a single self-contained
HTML dashboard: cd benchmarks/compare && pnpm report generates and opens
benchmarks/compare/report/report.html.
Self-benchmark (tinybench, warmup 100 ms, measure 500 ms per task, node v22.14.0):
Task ops/sec ±rme% mean ns
----------------- ---------- ----- -------
emit 1 handler 24,081,165 0.00 39.0
emit 10 handlers 19,779,190 0.02 58.1
emit 100 handlers 1,477,465 0.02 843.2
emit with 1 onAny 23,676,997 0.01 46.2
on+off churn 21,670,755 0.01 57.1
once+emit 19,606,729 0.02 60.2Reproduce with:
pnpm benchBenchmark: vs. the field
Compared against mitt, nanoevents, eventemitter3, tseep, and emittery (tinybench,
warmup 100 ms / measure 300 ms per task, node v22.14.0). Caveat: emittery's
emit returns a promise and each call is awaited, structurally slower than
sync dispatch; it trades raw speed for async listener support. n/a = library
lacks the capability.
emit 1 listener
Library ops/sec ±rme% mean ns
nanoevents 24,470,300 3.71 37.0
* use-typed-event 23,995,107 0.54 39.8
tseep 23,877,486 1.99 40.3
eventemitter3 21,846,900 0.21 50.4
mitt 20,513,520 9.48 82.1
emittery [async] 911,036 4.52 1,315emit 10 listeners
tseep 23,855,446 0.72 41.8
nanoevents 20,947,671 0.36 53.7
* use-typed-event 20,768,976 0.38 55.2
mitt 13,589,125 1.59 88.0
eventemitter3 6,350,488 0.38 164.4
emittery [async] 326,960 13.36 3,811emit 100 listeners
tseep 18,406,249 0.26 63.0
* use-typed-event 5,531,539 0.24 187.4
nanoevents 3,256,819 7.18 386.0
mitt 2,460,712 4.17 515.9
eventemitter3 865,394 0.24 1,182
emittery [async] 43,537 15.44 27,439on+off churn
eventemitter3 19,605,334 7.38 71.7
nanoevents 18,706,724 0.64 63.5
* use-typed-event 17,746,065 0.87 67.6
mitt 12,683,408 5.14 96.4
tseep 10,793,682 1.96 110.4
emittery [async] 724,642 7.17 1,665once+emit
tseep 20,713,625 17.36 87.3
eventemitter3 17,059,185 2.04 74.5
* use-typed-event 16,363,474 9.90 95.8
emittery [async] 340,328 6.85 3,329
mitt n/a (no once)
nanoevents n/a (no once)wildcard emit
* use-typed-event 23,900,276 0.43 41.7
mitt 19,361,322 9.70 73.6
emittery [async] 884,338 2.85 1,269
nanoevents / eventemitter3 / tseep n/a (no wildcard)Read honestly: use-typed-event has the fastest wildcard emit of the
libraries that support one (23.9M vs mitt's 18.8–19.4M) and the best
non-codegen result on emit with 100 listeners (~1.7× nanoevents). tseep
leads emit-heavy scenarios only through runtime code generation
(new Function); we deliberately avoid codegen for CSP compatibility and
bundle size. once+emit is statistically tied with eventemitter3 (the best
non-codegen result), ~1.25–1.3× behind codegen tseep. on+off churn is
borderline-tied with eventemitter3 (within 1.08–1.12×). On plain emit with 1
or 10 listeners, use-typed-event lands 2–4% behind nanoevents, the
structural cost of supporting once and onAny, which nanoevents lacks.
Reproduce with:
cd benchmarks/compare && pnpm install && pnpm benchBenchmark: React render behavior
25 hot + 25 cold subscribers, 100 bursts × 10 fires per burst (one act per
burst), 5 runs (5 discarded warmups) → median ± sd, mount excluded. Run on
[email protected].
Driver hot renders cold renders commits profiler ms wall ms
* use-typed-event 2500 ±0 0 ±0 100 ±0 4.91 ±0.32 11.53 ±0.83
use-bus 2500 ±0 0 ±0 100 ±0 4.69 ±0.34 11.23 ±0.61
zustand 2500 ±0 0 ±0 100 ±0 5.52 ±0.40 12.42 ±1.05The same run on [email protected] gives statistically tied profiler times per driver (within overlapping ±sd) with wall times uniformly ~1–2 ms lower; React version does not change the ranking or the render/commit counts.
Driver notes:
- use-typed-event:
createEventBus+useEventState, no provider, no extra wiring. - use-bus:
useBusonly invokes a callback, so rendering the payload needs a manualuseStateper component (the wiring a real user writes). - zustand: not an event bus; included as a state-management baseline.
Read honestly: all drivers are within ~1–3 ms wall time and render/commit counts are identical; every driver achieves perfect subscription isolation (cold subscribers never re-render). The claim is "no render-count penalty vs the rivals, with zero wiring and full typing", not a speed win.
Reproduce with:
cd benchmarks/compare && pnpm install && pnpm bench:reactBenchmark: bundle size
esbuild, minified ESM, react/react-dom externalized:
Library min B min+gzip B
nanoevents 359 255
mitt 451 278
use-bus 587 390
zustand 785 474
* use-typed-event 1,456 750
* use-typed-event/react 2,140 1,072
eventemitter3 3,503 1,362
tseep 18,284 3,703
emittery 10,659 3,756nanoevents and mitt stay smaller, but they also ship fewer capabilities (no once
for either, no React hooks, and mitt's wildcard is untyped by name). The React
entry at 1,072 B gzipped is the only one in this table that ships built-in
hooks.
Reproduce with:
cd benchmarks/compare && pnpm install && pnpm bench:sizeContributing
Contributions are welcome. See CONTRIBUTING.md for development setup, testing commands, and coding conventions.
License
MIT © Dominik Rycharski
