use-typed-event 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominik Rycharski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,516 @@
1
+ # use-typed-event
2
+
3
+ [![CI](https://github.com/doryski/use-typed-event/actions/workflows/ci.yml/badge.svg)](https://github.com/doryski/use-typed-event/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/use-typed-event)](https://www.npmjs.com/package/use-typed-event)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ > Minimal TS-native typed event emitter with **first-class React hooks**.
8
+ > End-to-end typed event map, correctly-typed wildcard, void events, `waitFor`,
9
+ > zero dependencies: **750 B gzipped core, 1,072 B gzipped React entry**.
10
+
11
+ ## Why
12
+
13
+ The existing options make you pick between typing, size, and React integration:
14
+
15
+ - **mitt** and **nanoevents** are wonderfully tiny, but ship **no React glue**:
16
+ wiring a component to an event means a manual `useState` + `useEffect` in every
17
+ component. And mitt's wildcard handler types the payload as a union of all
18
+ payloads without narrowing by name.
19
+
20
+ `use-typed-event` gives you all three: a single `EventMap` typed end-to-end
21
+ (`emit`, `on`, `once`, `off`, `waitFor`, and the wildcard all check against the same
22
+ map), a wildcard whose payload **narrows** when you narrow the name, `void` events
23
+ that take no payload argument, promise-based `waitFor` with `AbortSignal` support,
24
+ and two zero-wiring React hooks (`useEvent`, `useEventState`), all with zero runtime
25
+ dependencies.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pnpm add use-typed-event
31
+ # or
32
+ npm install use-typed-event
33
+ ```
34
+
35
+ Requires Node ≥ 20. Ships dual ESM + CJS builds with TypeScript types and has zero
36
+ runtime dependencies. `react` (^18 or ^19) is an **optional peer dependency**:
37
+ the core `use-typed-event` entry works without React; only the
38
+ `use-typed-event/react` entry needs it.
39
+
40
+ ## Usage
41
+
42
+ ### Core emitter
43
+
44
+ ```ts
45
+ import { createEmitter } from "use-typed-event"
46
+
47
+ type Events = {
48
+ "cart.add": { sku: string; qty: number }
49
+ "user.login": { id: string }
50
+ "app.ready": void
51
+ }
52
+
53
+ const emitter = createEmitter<Events>()
54
+
55
+ const unsubscribe = emitter.on("cart.add", ({ sku, qty }) => {
56
+ console.log(`added ${qty} × ${sku}`)
57
+ })
58
+
59
+ emitter.once("user.login", ({ id }) => console.log(`first login: ${id}`))
60
+
61
+ emitter.emit("cart.add", { sku: "A-1", qty: 2 })
62
+ emitter.emit("app.ready") // void event, no payload argument
63
+
64
+ unsubscribe() // idempotent, safe to call more than once
65
+ emitter.off("user.login") // omit the handler to remove every handler for a name
66
+ ```
67
+
68
+ Every subscription (`on`, `once`, `onAny`) returns an idempotent unsubscribe
69
+ function. `off(name, handler)` removes the first registration of that handler,
70
+ including `once` registrations, which are matched by the original handler
71
+ reference you passed in.
72
+
73
+ #### Typed wildcard with narrowing
74
+
75
+ `onAny` receives `(name, payload)` for every emit; narrowing on `name` narrows
76
+ `payload` to the mapped type:
77
+
78
+ ```ts
79
+ emitter.onAny((name, payload) => {
80
+ if (name === "cart.add") {
81
+ console.log(payload.qty) // payload is { sku: string; qty: number } here
82
+ }
83
+ })
84
+ ```
85
+
86
+ Named handlers are delivered first, then wildcard handlers.
87
+
88
+ #### `waitFor` with `AbortSignal`
89
+
90
+ `waitFor(name)` resolves with the next payload of `name`. Pass a `signal` to stop
91
+ waiting; the promise rejects with `signal.reason`:
92
+
93
+ ```ts
94
+ const { id } = await emitter.waitFor("user.login")
95
+
96
+ // give up after 5 seconds
97
+ await emitter.waitFor("app.ready", { signal: AbortSignal.timeout(5_000) })
98
+ ```
99
+
100
+ An already-aborted signal rejects immediately without subscribing.
101
+
102
+ ### Typed selectors
103
+
104
+ Event names are just strings, so every API above works with a plain string key.
105
+ But magic strings give up autocomplete and break silently on rename. Pass
106
+ `createSelectors<E>()` a typed accessor instead: each dot-access maps to the
107
+ `"."` separator in the key, so `events.cart.add === "cart.add"`.
108
+
109
+ ```ts
110
+ import { createEmitter, createSelectors } from "use-typed-event"
111
+
112
+ type Events = {
113
+ "cart.add": { sku: string; qty: number }
114
+ "user.login": { id: string }
115
+ "app.ready": void
116
+ }
117
+
118
+ const emitter = createEmitter<Events>()
119
+ const events = createSelectors<Events>()
120
+
121
+ emitter.emit(events.cart.add, { sku: "A-1", qty: 2 }) // same as emit("cart.add", ...)
122
+ emitter.on(events.user.login, ({ id }) => console.log(id))
123
+ await emitter.waitFor(events.app.ready)
124
+ ```
125
+
126
+ Selectors resolve to the exact string literal (`events.cart.add` has type
127
+ `"cart.add"`), so payload inference is identical to the string form: you get
128
+ autocomplete while typing the path and a compile error if a key is renamed or
129
+ removed. Strings and selectors are **fully interchangeable and share state**:
130
+ `emit(events.cart.add, p)` reaches `on("cart.add", …)` and vice-versa, because
131
+ both normalize to the same key.
132
+
133
+ #### Leaf-and-prefix collisions
134
+
135
+ When a key is **both a leaf and a namespace prefix** (an event map containing
136
+ both `"cart"` and `"cart.add"`), `events.cart` can't be both a string leaf and a
137
+ navigable object. The selector keeps navigating deeper and exposes the leaf
138
+ through the reserved **`$`** accessor:
139
+
140
+ ```ts
141
+ type Events = {
142
+ cart: { total: number }
143
+ "cart.add": { sku: string }
144
+ }
145
+
146
+ const events = createSelectors<Events>()
147
+
148
+ emitter.emit(events.cart.$, { total: 5 }) // the "cart" leaf; events.cart.$ is typed "cart"
149
+ emitter.emit(events.cart.add, { sku: "A-1" }) // still navigates to "cart.add"
150
+ ```
151
+
152
+ `$` is fully type-checked (`events.cart.$` has type `"cart"` with the right
153
+ payload) and works at any depth (`events.cart.item.$`). It is a **reserved**
154
+ segment, so avoid an event key with a literal `$` segment. If you'd rather not
155
+ use it, the plain string form (`emit("cart", …)`) always works, or you can avoid
156
+ the collision by namespacing the leaf (e.g. `"cart.total"` instead of `"cart"`).
157
+
158
+ ### React hooks
159
+
160
+ The `use-typed-event/react` entry gives you a bus factory whose hooks are already
161
+ bound to the emitter: no provider, no context, no wiring:
162
+
163
+ ```tsx
164
+ import { createEventBus } from "use-typed-event/react"
165
+
166
+ type Events = {
167
+ "cart.add": { sku: string; qty: number }
168
+ "counter.changed": number
169
+ }
170
+
171
+ const bus = createEventBus<Events>()
172
+
173
+ const CartBadge = () => {
174
+ const [lastAdd, setLastAdd] = bus.useEventState("cart.add")
175
+ return (
176
+ <button onClick={() => setLastAdd({ sku: "A-1", qty: 1 })}>
177
+ {lastAdd ? lastAdd.sku : "empty"}
178
+ </button>
179
+ )
180
+ }
181
+
182
+ const LoginToast = () => {
183
+ bus.useEvent("cart.add", ({ sku }) => showToast(`added ${sku}`))
184
+ return null
185
+ }
186
+
187
+ // emit from anywhere: event handlers, effects, non-React code
188
+ bus.emit("cart.add", { sku: "A-1", qty: 2 })
189
+ ```
190
+
191
+ The bus also exposes [typed selectors](#typed-selectors) as `bus.events`, so you
192
+ can drop magic strings on the React side too. Selector and string forms address
193
+ the same event and share state:
194
+
195
+ ```tsx
196
+ bus.emit(bus.events.cart.add, { sku: "A-1", qty: 2 })
197
+ const [lastAdd] = bus.useEventState(bus.events.cart.add)
198
+ bus.useEvent(bus.events.cart.add, ({ sku }) => showToast(`added ${sku}`))
199
+ ```
200
+
201
+ - **`useEventState(name)`** returns a `[value, setValue]` tuple. `value` is the
202
+ last emitted payload as React state (`undefined` until the first emit).
203
+ Payloads emitted **before** the component mounts are still visible; the bus
204
+ records the last payload of every event.
205
+ - **`useEventState(name, initialValue)`** types `value` as `E[K]` (no
206
+ `undefined`). The initial value is pinned on the first render; later changes
207
+ to it are ignored.
208
+ - **`setValue` emits on the bus**: it is `emit(name, ...)` in disguise, not
209
+ local state, so **every** subscriber of the event updates, not just the
210
+ calling component. Its argument follows the same rules as `emit`: none for
211
+ `void` events, optional when `undefined` is allowed, required otherwise. It
212
+ is referentially stable across renders for a given `name`.
213
+
214
+ ```tsx
215
+ const [count, setCount] = bus.useEventState("counter.changed", 0) // count: number, not number | undefined
216
+ setCount(count + 1) // === bus.emit("counter.changed", count + 1)
217
+ ```
218
+
219
+ - **`useEvent(name, handler)`** subscribes for the component's lifetime. The
220
+ latest `handler` is always invoked; changing its identity never resubscribes,
221
+ so you can pass inline closures freely. Changing `name` resubscribes.
222
+
223
+ #### Binding hooks to a shared core emitter
224
+
225
+ If an emitter already exists (e.g. shared with non-React code), bind hooks to it
226
+ with `createEventHooks`:
227
+
228
+ ```ts
229
+ import { createEmitter } from "use-typed-event"
230
+ import { createEventHooks } from "use-typed-event/react"
231
+
232
+ const emitter = createEmitter<Events>()
233
+ const { useEvent, useEventState } = createEventHooks(emitter)
234
+ ```
235
+
236
+ Note: `createEventHooks` installs a wildcard recorder on the emitter so
237
+ `useEventState` can see payloads emitted before any hook mounts. Calling
238
+ `emitter.clear()` directly also removes that recorder, so prefer `createEventBus`,
239
+ whose `clear` keeps recording intact (see below).
240
+
241
+ ### Semantics you should know
242
+
243
+ #### Mutation during emit
244
+
245
+ Delivery for an in-flight emit is stable: handlers **added during an emit are not
246
+ called** for that emit, and handlers **removed during an emit are still called**
247
+ for it (removal is copy-on-write). Both take effect from the next emit. A `once`
248
+ handler is unsubscribed before it runs, so it fires exactly once even if its own
249
+ emit re-enters.
250
+
251
+ #### Error propagation
252
+
253
+ Handlers are not wrapped in `try`/`catch`: a throwing handler propagates the
254
+ error to the `emit` caller and stops delivery to the remaining handlers for that
255
+ emit. If you need isolation, catch inside the handler.
256
+
257
+ #### `clear()` semantics
258
+
259
+ `bus.clear()` removes every handler (**including subscriptions of currently
260
+ mounted hooks**) and forgets all recorded payloads, then reinstalls the payload
261
+ recorder so subsequent emits keep being recorded. Mounted `useEventState`
262
+ components are **not notified**: they keep showing their current value and stop
263
+ receiving emits until they resubscribe (a `name` change or a remount). Components
264
+ mounted after `clear()` see the initial value again.
265
+
266
+ #### Last-payload memory (dynamic event names)
267
+
268
+ The bus records the last payload of **every emitted event name** for its
269
+ lifetime. With a fixed, finite event map this is a few map entries; but if you
270
+ emit under high-cardinality or dynamically generated names, the map grows without
271
+ bound until `clear()` is called.
272
+
273
+ #### Removed-handler tombstones (dynamic event names)
274
+
275
+ The core emitter has a related property: removing a handler tombstones the
276
+ event name's map slot instead of deleting the key; only `off(name)` and
277
+ `clear()` truly delete. With a fixed event map this is negligible, but
278
+ emitting or subscribing under unbounded unique names retains a map key per
279
+ name for the emitter's lifetime.
280
+
281
+ #### SSR
282
+
283
+ `useEventState` is built on `useSyncExternalStore` and provides a
284
+ `getServerSnapshot` that returns the initial value (or `undefined` without one),
285
+ so server rendering works out of the box and hydrates consistently.
286
+
287
+ #### Same-value emits don't re-render
288
+
289
+ `useEventState` re-renders only when the payload changes by `Object.is`; emitting
290
+ the same value twice does not re-render. If you need to react to **every
291
+ occurrence** of an event (e.g. `emit("save")` twice in a row), use `useEvent`,
292
+ which invokes the handler on every emit regardless of the payload.
293
+
294
+ #### Known type limitation: unions mixing `void` and payload events
295
+
296
+ When the event name is a union mixing a `void` event with payload-carrying ones
297
+ (e.g. `emit(cond ? "app.ready" : "user.login")`), the payload type becomes a union
298
+ containing `void`, which turns the payload argument optional: the call compiles
299
+ with zero arguments even though `"user.login"` alone requires one. This
300
+ non-distributive form of `PayloadArgs` is a deliberate ergonomics tradeoff;
301
+ call `emit` with a literal name to keep full checking.
302
+
303
+ ## API reference
304
+
305
+ ### `use-typed-event` (core)
306
+
307
+ | Export | Signature | Notes |
308
+ | --- | --- | --- |
309
+ | `createEmitter` | `createEmitter<E extends EventMap>(): Emitter<E>` | Creates a typed emitter for the event map `E`. |
310
+ | `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. |
311
+ | `Emitter.on` | `on(name, handler): Unsubscribe` | Registers a handler; returns an idempotent unsubscribe. |
312
+ | `Emitter.once` | `once(name, handler): Unsubscribe` | Invoked at most once; unsubscribed before it runs. |
313
+ | `Emitter.off` | `off(name, handler?): void` | Removes the first registration of `handler` (matches `once` by original reference), or every handler for `name` when omitted. |
314
+ | `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. |
315
+ | `Emitter.onAny` | `onAny(handler): Unsubscribe` | Wildcard handler `(name, payload)`; narrowing `name` narrows `payload`. |
316
+ | `Emitter.waitFor` | `waitFor(name, options?): Promise<E[K]>` | Resolves with the next payload; `options.signal` rejects with `signal.reason` on abort. |
317
+ | `Emitter.clear` | `clear(): void` | Removes every named and wildcard handler. |
318
+ | Types | `EventMap`, `Emitter`, `EventHandler`, `AnyHandler`, `EventTuple`, `PayloadArgs`, `Unsubscribe`, `WaitForOptions`, `EventSelectors` | |
319
+
320
+ ### `use-typed-event/react`
321
+
322
+ | Export | Signature | Notes |
323
+ | --- | --- | --- |
324
+ | `createEventBus` | `createEventBus<E extends EventMap>(): EventBus<E>` | Fresh emitter + bound hooks in one object; its `clear()` also forgets recorded payloads and keeps recording working. |
325
+ | `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`. |
326
+ | `createEventHooks` | `createEventHooks<E>(emitter: Emitter<E>): EventHooks<E>` | Binds `useEvent` / `useEventState` to an existing emitter (installs a payload recorder). |
327
+ | `useEvent` | `useEvent(name, handler): void` | Subscribes for the component's lifetime; always calls the latest handler, never resubscribes on handler identity change. |
328
+ | `useEventState` | `useEventState(name): [E[K] \| undefined, setter]`<br>`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`. |
329
+ | Types | `EventBus`, `EventHooks`, `UseEvent`, `UseEventState`, `EventSelectors` (+ re-exports everything from the core entry) | |
330
+
331
+ ## Benchmark: emitter throughput
332
+
333
+ All three comparison suites below can also be viewed as a single self-contained
334
+ HTML dashboard: `cd benchmarks/compare && pnpm report` generates and opens
335
+ `benchmarks/compare/report/report.html`.
336
+
337
+ Self-benchmark (tinybench, warmup 100 ms, measure 500 ms per task, node v22.14.0):
338
+
339
+ ```
340
+ Task ops/sec ±rme% mean ns
341
+ ----------------- ---------- ----- -------
342
+ emit 1 handler 24,081,165 0.00 39.0
343
+ emit 10 handlers 19,779,190 0.02 58.1
344
+ emit 100 handlers 1,477,465 0.02 843.2
345
+ emit with 1 onAny 23,676,997 0.01 46.2
346
+ on+off churn 21,670,755 0.01 57.1
347
+ once+emit 19,606,729 0.02 60.2
348
+ ```
349
+
350
+ Reproduce with:
351
+
352
+ ```bash
353
+ pnpm bench
354
+ ```
355
+
356
+ ## Benchmark: vs. the field
357
+
358
+ Compared against mitt, nanoevents, eventemitter3, tseep, and emittery (tinybench,
359
+ warmup 100 ms / measure 300 ms per task, node v22.14.0). Caveat: **emittery's
360
+ `emit` returns a promise and each call is awaited**, structurally slower than
361
+ sync dispatch; it trades raw speed for async listener support. `n/a` = library
362
+ lacks the capability.
363
+
364
+ ### emit 1 listener
365
+
366
+ ```
367
+ Library ops/sec ±rme% mean ns
368
+ nanoevents 24,470,300 3.71 37.0
369
+ * use-typed-event 23,995,107 0.54 39.8
370
+ tseep 23,877,486 1.99 40.3
371
+ eventemitter3 21,846,900 0.21 50.4
372
+ mitt 20,513,520 9.48 82.1
373
+ emittery [async] 911,036 4.52 1,315
374
+ ```
375
+
376
+ ### emit 10 listeners
377
+
378
+ ```
379
+ tseep 23,855,446 0.72 41.8
380
+ nanoevents 20,947,671 0.36 53.7
381
+ * use-typed-event 20,768,976 0.38 55.2
382
+ mitt 13,589,125 1.59 88.0
383
+ eventemitter3 6,350,488 0.38 164.4
384
+ emittery [async] 326,960 13.36 3,811
385
+ ```
386
+
387
+ ### emit 100 listeners
388
+
389
+ ```
390
+ tseep 18,406,249 0.26 63.0
391
+ * use-typed-event 5,531,539 0.24 187.4
392
+ nanoevents 3,256,819 7.18 386.0
393
+ mitt 2,460,712 4.17 515.9
394
+ eventemitter3 865,394 0.24 1,182
395
+ emittery [async] 43,537 15.44 27,439
396
+ ```
397
+
398
+ ### on+off churn
399
+
400
+ ```
401
+ eventemitter3 19,605,334 7.38 71.7
402
+ nanoevents 18,706,724 0.64 63.5
403
+ * use-typed-event 17,746,065 0.87 67.6
404
+ mitt 12,683,408 5.14 96.4
405
+ tseep 10,793,682 1.96 110.4
406
+ emittery [async] 724,642 7.17 1,665
407
+ ```
408
+
409
+ ### once+emit
410
+
411
+ ```
412
+ tseep 20,713,625 17.36 87.3
413
+ eventemitter3 17,059,185 2.04 74.5
414
+ * use-typed-event 16,363,474 9.90 95.8
415
+ emittery [async] 340,328 6.85 3,329
416
+ mitt n/a (no once)
417
+ nanoevents n/a (no once)
418
+ ```
419
+
420
+ ### wildcard emit
421
+
422
+ ```
423
+ * use-typed-event 23,900,276 0.43 41.7
424
+ mitt 19,361,322 9.70 73.6
425
+ emittery [async] 884,338 2.85 1,269
426
+ nanoevents / eventemitter3 / tseep n/a (no wildcard)
427
+ ```
428
+
429
+ Read honestly: use-typed-event has the **fastest wildcard emit** of the
430
+ libraries that support one (23.9M vs mitt's 18.8–19.4M) and the **best
431
+ non-codegen result on emit with 100 listeners** (~1.7× nanoevents). tseep
432
+ leads emit-heavy scenarios only through runtime code generation
433
+ (`new Function`); we deliberately avoid codegen for CSP compatibility and
434
+ bundle size. **once+emit** is statistically tied with eventemitter3 (the best
435
+ non-codegen result), ~1.25–1.3× behind codegen tseep. **on+off churn** is
436
+ borderline-tied with eventemitter3 (within 1.08–1.12×). On plain emit with 1
437
+ or 10 listeners, use-typed-event lands 2–4% behind nanoevents, the
438
+ structural cost of supporting `once` and `onAny`, which nanoevents lacks.
439
+
440
+ Reproduce with:
441
+
442
+ ```bash
443
+ cd benchmarks/compare && pnpm install && pnpm bench
444
+ ```
445
+
446
+ ## Benchmark: React render behavior
447
+
448
+ 25 hot + 25 cold subscribers, 100 bursts × 10 fires per burst (one `act` per
449
+ burst), 5 runs (5 discarded warmups) → median ± sd, mount excluded. Run on
450
+ **react@19.2.7**.
451
+
452
+ ```
453
+ Driver hot renders cold renders commits profiler ms wall ms
454
+ * use-typed-event 2500 ±0 0 ±0 100 ±0 4.91 ±0.32 11.53 ±0.83
455
+ use-bus 2500 ±0 0 ±0 100 ±0 4.69 ±0.34 11.23 ±0.61
456
+ zustand 2500 ±0 0 ±0 100 ±0 5.52 ±0.40 12.42 ±1.05
457
+ ```
458
+
459
+ The same run on react@18.3.1 gives statistically tied profiler times per driver
460
+ (within overlapping ±sd) with wall times uniformly ~1–2 ms lower; React
461
+ version does not change the ranking or the render/commit counts.
462
+
463
+ Driver notes:
464
+
465
+ - **use-typed-event**: `createEventBus` + `useEventState`, no provider, no extra wiring.
466
+ - **use-bus**: `useBus` only invokes a callback, so rendering the payload needs a
467
+ manual `useState` per component (the wiring a real user writes).
468
+ - **zustand**: **not an event bus**; included as a state-management baseline.
469
+
470
+ Read honestly: all drivers are within ~1–3 ms wall time and render/commit counts
471
+ are identical; every driver achieves perfect subscription isolation (cold
472
+ subscribers never re-render). The claim is **"no render-count penalty vs the
473
+ rivals, with zero wiring and full typing"**, not a speed win.
474
+
475
+ Reproduce with:
476
+
477
+ ```bash
478
+ cd benchmarks/compare && pnpm install && pnpm bench:react
479
+ ```
480
+
481
+ ## Benchmark: bundle size
482
+
483
+ esbuild, minified ESM, `react`/`react-dom` externalized:
484
+
485
+ ```
486
+ Library min B min+gzip B
487
+ nanoevents 359 255
488
+ mitt 451 278
489
+ use-bus 587 390
490
+ zustand 785 474
491
+ * use-typed-event 1,456 750
492
+ * use-typed-event/react 2,140 1,072
493
+ eventemitter3 3,503 1,362
494
+ tseep 18,284 3,703
495
+ emittery 10,659 3,756
496
+ ```
497
+
498
+ nanoevents and mitt stay smaller, but they also ship fewer capabilities (no `once`
499
+ for either, no React hooks, and mitt's wildcard is untyped by name). The React
500
+ entry at 1,072 B gzipped is the only one in this table that ships built-in
501
+ hooks.
502
+
503
+ Reproduce with:
504
+
505
+ ```bash
506
+ cd benchmarks/compare && pnpm install && pnpm bench:size
507
+ ```
508
+
509
+ ## Contributing
510
+
511
+ Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for development
512
+ setup, testing commands, and coding conventions.
513
+
514
+ ## License
515
+
516
+ [MIT](./LICENSE) © Dominik Rycharski
@@ -0,0 +1,133 @@
1
+ // src/emitter.ts
2
+ var matchesHandler = (registered, target) => registered === target || typeof registered !== "function" && registered.handler === target;
3
+ var keyOf = (name) => typeof name === "string" ? name : String(name);
4
+ var copyWithout = (list, index) => {
5
+ const length = list.length;
6
+ const next = new Array(length - 1);
7
+ for (let i = 0; i < index; i++) next[i] = list[i];
8
+ for (let i = index + 1; i < length; i++) next[i - 1] = list[i];
9
+ return next;
10
+ };
11
+ var createEmitter = () => {
12
+ const handlers = /* @__PURE__ */ new Map();
13
+ let anyHandlers = [];
14
+ const removeFromList = (name, slot, target) => {
15
+ const length = slot.length;
16
+ let index = 0;
17
+ while (index < length && !matchesHandler(slot[index], target)) index++;
18
+ if (index === length) return;
19
+ if (length === 2) handlers.set(name, slot[1 - index]);
20
+ else handlers.set(name, copyWithout(slot, index));
21
+ };
22
+ const removeNamed = (name, target) => {
23
+ const slot = handlers.get(name);
24
+ if (slot === void 0) return;
25
+ if (Array.isArray(slot)) removeFromList(name, slot, target);
26
+ else if (matchesHandler(slot, target)) handlers.set(name, void 0);
27
+ };
28
+ const subscribe = (name, entry) => {
29
+ const key = keyOf(name);
30
+ const slot = handlers.get(key);
31
+ if (slot === void 0) handlers.set(key, entry);
32
+ else if (Array.isArray(slot)) slot.push(entry);
33
+ else handlers.set(key, [slot, entry]);
34
+ let active = true;
35
+ return () => {
36
+ if (!active) return;
37
+ active = false;
38
+ const current = handlers.get(key);
39
+ if (current === entry) handlers.set(key, void 0);
40
+ else if (Array.isArray(current)) removeFromList(key, current, entry);
41
+ };
42
+ };
43
+ const once = (name, handler) => subscribe(keyOf(name), { handler });
44
+ const off = (name, handler) => {
45
+ const key = keyOf(name);
46
+ if (!handler) {
47
+ handlers.delete(key);
48
+ return;
49
+ }
50
+ removeNamed(key, handler);
51
+ };
52
+ const emit = (name, payload) => {
53
+ const key = keyOf(name);
54
+ const slot = handlers.get(key);
55
+ if (slot !== void 0) {
56
+ if (typeof slot === "function") slot(payload);
57
+ else if (Array.isArray(slot)) {
58
+ const length = slot.length;
59
+ for (let i = 0; i < length; i++) {
60
+ const entry = slot[i];
61
+ if (typeof entry === "function") entry(payload);
62
+ else {
63
+ removeNamed(key, entry);
64
+ entry.handler(payload);
65
+ }
66
+ }
67
+ } else {
68
+ handlers.set(key, void 0);
69
+ slot.handler(payload);
70
+ }
71
+ }
72
+ const anyList = anyHandlers;
73
+ const anyLength = anyList.length;
74
+ for (let i = 0; i < anyLength; i++) anyList[i](key, payload);
75
+ };
76
+ const onAny = (handler) => {
77
+ anyHandlers.push(handler);
78
+ let active = true;
79
+ return () => {
80
+ if (!active) return;
81
+ active = false;
82
+ const index = anyHandlers.indexOf(handler);
83
+ if (index !== -1) anyHandlers = copyWithout(anyHandlers, index);
84
+ };
85
+ };
86
+ const waitFor = (name, options) => {
87
+ const key = keyOf(name);
88
+ const signal = options?.signal;
89
+ if (signal?.aborted) return Promise.reject(signal.reason);
90
+ return new Promise((resolve, reject) => {
91
+ const onAbort = () => {
92
+ unsubscribe();
93
+ reject(signal?.reason);
94
+ };
95
+ const unsubscribe = once(key, (payload) => {
96
+ signal?.removeEventListener("abort", onAbort);
97
+ resolve(payload);
98
+ });
99
+ signal?.addEventListener("abort", onAbort, { once: true });
100
+ });
101
+ };
102
+ const clear = () => {
103
+ handlers.clear();
104
+ anyHandlers = [];
105
+ };
106
+ return {
107
+ on: subscribe,
108
+ once,
109
+ off,
110
+ emit,
111
+ onAny,
112
+ waitFor,
113
+ clear
114
+ };
115
+ };
116
+
117
+ // src/selectors.ts
118
+ var build = (path) => new Proxy({}, {
119
+ get(_t, prop) {
120
+ if (prop === Symbol.toPrimitive || prop === "toString" || prop === "valueOf")
121
+ return () => path.join(".");
122
+ if (prop === "$") return path.join(".");
123
+ if (typeof prop === "symbol") return void 0;
124
+ return build([...path, prop]);
125
+ }
126
+ });
127
+ var createSelectors = () => build([]);
128
+
129
+ export {
130
+ keyOf,
131
+ createEmitter,
132
+ createSelectors
133
+ };