npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

use-typed-event

CI npm version License: MIT

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 + useEffect in 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-event

Requires 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 name

Every 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. value is the last emitted payload as React state (undefined until the first emit). Payloads emitted before the component mounts are still visible; the bus records the last payload of every event.
  • useEventState(name, initialValue) types value as E[K] (no undefined). The initial value is pinned on the first render; later changes to it are ignored.
  • setValue emits on the bus: it is emit(name, ...) in disguise, not local state, so every subscriber of the event updates, not just the calling component. Its argument follows the same rules as emit: none for void events, optional when undefined is allowed, required otherwise. It is referentially stable across renders for a given name.
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 latest handler is always invoked; changing its identity never resubscribes, so you can pass inline closures freely. Changing name resubscribes.

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.2

Reproduce with:

pnpm bench

Benchmark: 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,315

emit 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,811

emit 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,439

on+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,665

once+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 bench

Benchmark: 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.05

The 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: useBus only invokes a callback, so rendering the payload needs a manual useState per 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:react

Benchmark: 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,756

nanoevents 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:size

Contributing

Contributions are welcome. See CONTRIBUTING.md for development setup, testing commands, and coding conventions.

License

MIT © Dominik Rycharski