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 +21 -0
- package/README.md +516 -0
- package/dist/chunk-ZRE57KHP.js +133 -0
- package/dist/index.cjs +159 -0
- package/dist/index.d.cts +80 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +8 -0
- package/dist/react.cjs +234 -0
- package/dist/react.d.cts +50 -0
- package/dist/react.d.ts +50 -0
- package/dist/react.js +82 -0
- package/package.json +100 -0
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
|
+
[](https://github.com/doryski/use-typed-event/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/use-typed-event)
|
|
5
|
+
[](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
|
+
};
|