@take-out/docs 0.0.42

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/emitters.md ADDED
@@ -0,0 +1,562 @@
1
+ ---
2
+ name: emitters
3
+ description: Event emitters and pub/sub pattern for ephemeral UI state management. INVOKE WHEN: event emitters, pub/sub, ephemeral state, temporary state, transient state, UI events, event coordination, subscribe, emit, listen, @take-out/helpers emitters.
4
+ ---
5
+
6
+ # Emitters Guide - Event-Based State Management
7
+
8
+ Emitters are a lightweight event-based state management pattern used throughout
9
+ the codebase for handling ephemeral state and events. They're designed for
10
+ transient state that doesn't need persistence - think UI interactions, temporary
11
+ states, and event coordination.
12
+
13
+ Warning: Emitters should only be used for ephemeral state events. For persistent
14
+ state, use Zero queries. Overusing emitters can lead to hard-to-debug event
15
+ chains and memory leaks.
16
+
17
+ ## Core Concepts
18
+
19
+ An emitter is a simple pub-sub pattern with:
20
+
21
+ - A current value
22
+ - Listeners that react to changes
23
+ - Type-safe event emissions
24
+ - Optional comparison logic to prevent unnecessary updates
25
+
26
+ ## Basic Usage
27
+
28
+ ```tsx
29
+ import { createEmitter, useEmitterValue, useEmitter } from '@take-out/helpers'
30
+
31
+ // simple value emitter
32
+ const counterEmitter = createEmitter('counter', 0)
33
+
34
+ // emit a new value
35
+ counterEmitter.emit(5)
36
+
37
+ // get current value directly
38
+ console.info(counterEmitter.value) // 5
39
+
40
+ // listen to changes
41
+ const unsubscribe = counterEmitter.listen((value) => {
42
+ console.info('Counter changed:', value)
43
+ })
44
+
45
+ // clean up listener
46
+ unsubscribe()
47
+ ```
48
+
49
+ ## Comparison Strategies
50
+
51
+ Comparators control when listeners are notified:
52
+
53
+ - isEqualIdentity: Only emit if value !== previous (default for primitives)
54
+ - isEqualNever: Always emit, even for same values (useful for events)
55
+ - isEqualDeep: Only emit if deeply different (for objects/arrays)
56
+ - Custom: Provide your own comparison function
57
+
58
+ ```tsx
59
+ import { isEqualIdentity, isEqualNever, isEqualDeep } from '@take-out/helpers'
60
+
61
+ // always emit (good for action events)
62
+ const actionEmitter = createEmitter(
63
+ 'action',
64
+ { type: 'idle' },
65
+ { comparator: isEqualNever },
66
+ )
67
+
68
+ // only emit if value changes (identity check)
69
+ const idEmitter = createEmitter('currentId', null as string | null, {
70
+ comparator: isEqualIdentity,
71
+ })
72
+
73
+ // deep equality check for objects
74
+ const configEmitter = createEmitter(
75
+ 'config',
76
+ { theme: 'dark', fontSize: 14 },
77
+ { comparator: isEqualDeep },
78
+ )
79
+ ```
80
+
81
+ ## React Hooks
82
+
83
+ ### useEmitterValue - Subscribe to value changes
84
+
85
+ ```tsx
86
+ function MyComponent() {
87
+ const counter = useEmitterValue(counterEmitter)
88
+ return <div>Count: {counter}</div>
89
+ }
90
+ ```
91
+
92
+ ### useEmitter - React to emissions without storing value
93
+
94
+ ```tsx
95
+ function ActionHandler() {
96
+ useEmitter(actionEmitter, (action) => {
97
+ switch (action.type) {
98
+ case 'submit':
99
+ handleSubmit()
100
+ break
101
+ case 'cancel':
102
+ handleCancel()
103
+ break
104
+ }
105
+ })
106
+ return null
107
+ }
108
+ ```
109
+
110
+ ### useEmitterSelector - Derive state from emitter value
111
+
112
+ ```tsx
113
+ function DerivedState() {
114
+ const isEven = useEmitterSelector(counterEmitter, (count) => count % 2 === 0)
115
+ return <div>Is even: {isEven}</div>
116
+ }
117
+ ```
118
+
119
+ ### useEmittersSelector - Combine multiple emitters
120
+
121
+ ```tsx
122
+ function CombinedState() {
123
+ const combined = useEmittersSelector(
124
+ [counterEmitter, idEmitter] as const,
125
+ ([count, id]) => ({ count, id }),
126
+ )
127
+ return (
128
+ <div>
129
+ Count: {combined.count}, ID: {combined.id}
130
+ </div>
131
+ )
132
+ }
133
+ ```
134
+
135
+ ## Advanced Patterns
136
+
137
+ ### Type-safe action emitters with discriminated unions
138
+
139
+ ```tsx
140
+ type MessageInputAction =
141
+ | { type: 'submit'; id: string }
142
+ | { type: 'focus' }
143
+ | { type: 'clear' }
144
+ | { type: 'autocomplete-user'; user: User; value: string }
145
+ | { type: 'autocomplete-emoji'; emoji: string; value: string }
146
+
147
+ const messageInputController = createEmitter<MessageInputAction>(
148
+ 'messageInput',
149
+ { type: 'clear' },
150
+ { comparator: isEqualNever },
151
+ )
152
+
153
+ // usage with type safety
154
+ messageInputController.emit({ type: 'submit', id: '123' })
155
+ messageInputController.emit({
156
+ type: 'autocomplete-user',
157
+ user: someUser,
158
+ value: '@john',
159
+ })
160
+ ```
161
+
162
+ ### Awaiting next value with promises
163
+
164
+ ```tsx
165
+ async function waitForConfirmation() {
166
+ const confirmEmitter = createEmitter('confirm', false)
167
+
168
+ showConfirmDialog()
169
+
170
+ const confirmed = await confirmEmitter.nextValue()
171
+
172
+ if (confirmed) {
173
+ performAction()
174
+ }
175
+ }
176
+ ```
177
+
178
+ ### Global emitters that survive HMR
179
+
180
+ ```tsx
181
+ import { createGlobalEmitter } from '@take-out/helpers'
182
+
183
+ const globalStateEmitter = createGlobalEmitter('globalState', {
184
+ theme: 'dark',
185
+ sidebarOpen: true,
186
+ })
187
+ ```
188
+
189
+ ### Contextual emitters for component trees
190
+
191
+ ```tsx
192
+ import { createContextualEmitter } from '@take-out/helpers'
193
+
194
+ const [useChannelEmitter, ChannelEmitterProvider] =
195
+ createContextualEmitter<string>('channelId', { comparator: isEqualIdentity })
196
+
197
+ // provider at parent level
198
+ function ChannelView({ channelId }) {
199
+ return (
200
+ <ChannelEmitterProvider value={channelId}>
201
+ <ChannelContent />
202
+ </ChannelEmitterProvider>
203
+ )
204
+ }
205
+
206
+ // consumer in child components
207
+ function ChannelContent() {
208
+ const channelEmitter = useChannelEmitter()
209
+ const channelId = useEmitterValue(channelEmitter)
210
+ // ...
211
+ }
212
+ ```
213
+
214
+ ### Silent emitters (no console output)
215
+
216
+ ```tsx
217
+ const positionEmitter = createEmitter(
218
+ 'position',
219
+ { x: 0, y: 0 },
220
+ { silent: true },
221
+ )
222
+ ```
223
+
224
+ ## Real-World Examples from Codebase
225
+
226
+ ### Gallery/Modal Control
227
+
228
+ Shows/hides UI elements with nullable state
229
+
230
+ ```tsx
231
+ import { galleryEmitter } from '~/features/gallery/galleryEmitter'
232
+
233
+ // open gallery with items
234
+ galleryEmitter.emit({
235
+ items: attachments,
236
+ firstItem: attachmentId,
237
+ })
238
+
239
+ // close gallery
240
+ galleryEmitter.emit(null)
241
+
242
+ // component usage
243
+ function Gallery() {
244
+ const galleryData = useEmitterValue(galleryEmitter)
245
+ if (!galleryData) return null
246
+ return <GalleryView {...galleryData} />
247
+ }
248
+ ```
249
+
250
+ ### Error Handling
251
+
252
+ Centralized error reporting
253
+
254
+ ```tsx
255
+ import { errorEmitter } from '~/features/errors/errorEmitter'
256
+
257
+ // report error from anywhere
258
+ errorEmitter.emit({
259
+ error: new Error('Something went wrong'),
260
+ context: 'user-action',
261
+ timestamp: Date.now(),
262
+ })
263
+
264
+ // global error handler
265
+ function ErrorBoundary() {
266
+ useEmitter(errorEmitter, (errorReport) => {
267
+ if (errorReport) {
268
+ logToSentry(errorReport)
269
+ showErrorToast(errorReport.error.message)
270
+ }
271
+ })
272
+ }
273
+ ```
274
+
275
+ ### Autocomplete System
276
+
277
+ Complex stateful interactions
278
+
279
+ ```tsx
280
+ import { autocompleteEmitter } from '~/features/autocomplete/autocompleteEmitter'
281
+
282
+ type AutocompleteEvent =
283
+ | { type: 'open'; query: string; position: DOMRect }
284
+ | { type: 'close' }
285
+ | { type: 'select'; item: AutocompleteItem }
286
+
287
+ // trigger autocomplete
288
+ autocompleteEmitter.emit({
289
+ type: 'open',
290
+ query: '@use',
291
+ position: inputElement.getBoundingClientRect(),
292
+ })
293
+ ```
294
+
295
+ ### Message Highlighting
296
+
297
+ Temporary UI states
298
+
299
+ ```tsx
300
+ import { messageItemEmitter } from '~/features/message/messageItemEmitter'
301
+
302
+ // highlight a message temporarily
303
+ messageItemEmitter.emit({ type: 'highlight', id: messageId })
304
+
305
+ // in message component
306
+ function MessageItem({ message }) {
307
+ const [isHighlighted, setIsHighlighted] = useState(false)
308
+
309
+ useEmitter(messageItemEmitter, (event) => {
310
+ if (event.type === 'highlight' && event.id === message.id) {
311
+ setIsHighlighted(true)
312
+ setTimeout(() => setIsHighlighted(false), 1000)
313
+ }
314
+ })
315
+
316
+ return <div className={isHighlighted ? 'highlight' : ''}>{message.text}</div>
317
+ }
318
+ ```
319
+
320
+ ### Toast/Notification Queue
321
+
322
+ Event-driven notifications
323
+
324
+ ```tsx
325
+ const notificationEmitter = createEmitter<
326
+ | { type: 'show'; notification: Notification }
327
+ | { type: 'hide'; id: string }
328
+ | { type: 'hide_all' }
329
+ >('notifications', { type: 'hide_all' })
330
+
331
+ // show notification
332
+ notificationEmitter.emit({
333
+ type: 'show',
334
+ notification: {
335
+ id: Date.now().toString(),
336
+ title: 'New message',
337
+ body: 'You have a new message from John',
338
+ },
339
+ })
340
+ ```
341
+
342
+ ### Dialog Management
343
+
344
+ Coordinating async UI flows
345
+
346
+ ```tsx
347
+ import { confirmEmitter, dialogEmit } from '~/interface/dialogs/actions'
348
+
349
+ async function deleteMessage(messageId: string) {
350
+ dialogEmit({
351
+ type: 'confirm',
352
+ title: 'Delete Message?',
353
+ description: 'This action cannot be undone.',
354
+ })
355
+
356
+ const confirmed = await confirmEmitter.nextValue()
357
+
358
+ if (confirmed) {
359
+ await api.deleteMessage(messageId)
360
+ }
361
+ }
362
+ ```
363
+
364
+ ### Server Tint Color
365
+
366
+ Propagating theme changes
367
+
368
+ ```tsx
369
+ import { serverTintEmitter } from '~/features/server/serverTintEmitter'
370
+
371
+ // provider updates tint when server changes
372
+ function ServerTintProvider() {
373
+ const { server } = useCurrentServer()
374
+
375
+ useLayoutEffect(() => {
376
+ serverTintEmitter.emit(server?.tint || 0)
377
+ }, [server?.tint])
378
+
379
+ return null
380
+ }
381
+
382
+ // consumer components react to tint changes
383
+ function ThemedButton() {
384
+ const tint = useEmitterValue(serverTintEmitter)
385
+ const color = tint > 0 ? colors[tint] : 'default'
386
+ return <Button color={color} />
387
+ }
388
+ ```
389
+
390
+ ## Best Practices
391
+
392
+ DO:
393
+
394
+ - Use for ephemeral UI state (modals, tooltips, highlights)
395
+ - Use for event coordination between distant components
396
+ - Use for temporary user interactions
397
+ - Use for one-time events and notifications
398
+ - Clean up listeners in useEffect/useLayoutEffect
399
+ - Use appropriate comparators for your data type
400
+ - Name emitters clearly and consistently
401
+
402
+ DON'T:
403
+
404
+ - Use for persistent application state (use Zero/Jotai instead)
405
+ - Use for data that needs to survive page refreshes
406
+ - Create complex chains of emitters triggering each other
407
+ - Forget to unsubscribe listeners (memory leaks)
408
+ - Use for frequently changing values like scroll position without throttling
409
+ - Store large objects that could be in proper state management
410
+ - Use when simple React state or props would suffice
411
+
412
+ ## Debugging
413
+
414
+ Emitters support debug logging when DEBUG_LEVEL > 1:
415
+
416
+ 1. Set DEBUG_LEVEL in environment/devtools
417
+ 2. Non-silent emitters will log emissions with stack traces
418
+ 3. Use browser DevTools to filter by emitter name
419
+
420
+ Example console output:
421
+
422
+ ```
423
+ 📣 messageInput
424
+ { type: 'submit', id: '123' }
425
+ trace > [stack trace]
426
+ ```
427
+
428
+ ## Memory Management
429
+
430
+ ### Preventing memory leaks:
431
+
432
+ ```tsx
433
+ // bad: listener never cleaned up
434
+ function LeakyComponent() {
435
+ useEffect(() => {
436
+ myEmitter.listen((value) => {
437
+ doSomething(value)
438
+ })
439
+ }, [])
440
+ }
441
+
442
+ // good: proper cleanup
443
+ function CleanComponent() {
444
+ useEffect(() => {
445
+ const unsubscribe = myEmitter.listen((value) => {
446
+ doSomething(value)
447
+ })
448
+ return unsubscribe
449
+ }, [])
450
+ }
451
+
452
+ // better: use the hook (handles cleanup automatically)
453
+ function BestComponent() {
454
+ useEmitter(myEmitter, (value) => {
455
+ doSomething(value)
456
+ })
457
+ }
458
+ ```
459
+
460
+ ## Testing
461
+
462
+ Emitters are easy to test:
463
+
464
+ ```tsx
465
+ import { describe, it, expect, vi } from 'vitest'
466
+
467
+ describe('Emitter Testing', () => {
468
+ it('should emit values to listeners', () => {
469
+ const emitter = createEmitter('test', 0)
470
+ const listener = vi.fn()
471
+
472
+ const unsubscribe = emitter.listen(listener)
473
+ emitter.emit(5)
474
+
475
+ expect(listener).toHaveBeenCalledWith(5)
476
+ expect(emitter.value).toBe(5)
477
+
478
+ unsubscribe()
479
+ })
480
+
481
+ it('should handle async nextValue', async () => {
482
+ const emitter = createEmitter('async', false)
483
+
484
+ setTimeout(() => emitter.emit(true), 100)
485
+
486
+ const value = await emitter.nextValue()
487
+ expect(value).toBe(true)
488
+ })
489
+ })
490
+ ```
491
+
492
+ ## Common Pitfalls & Solutions
493
+
494
+ ### Infinite loops with emitters in effects
495
+
496
+ ```tsx
497
+ // bad: creates infinite loop
498
+ function InfiniteLoop() {
499
+ useEmitter(emitterA, (value) => {
500
+ emitterB.emit(value)
501
+ })
502
+ }
503
+
504
+ // good: break circular dependencies
505
+ function SafeEmission() {
506
+ useEmitter(emitterA, (value) => {
507
+ if (shouldUpdate(value)) {
508
+ emitterB.emit(transform(value))
509
+ }
510
+ })
511
+ }
512
+ ```
513
+
514
+ ### Using emitters for computed values
515
+
516
+ ```tsx
517
+ // bad: unnecessary emitter for derived state
518
+ const doubledEmitter = createEmitter('doubled', 0)
519
+ useEmitter(counterEmitter, (count) => {
520
+ doubledEmitter.emit(count * 2)
521
+ })
522
+
523
+ // good: use selector for derived values
524
+ const doubled = useEmitterSelector(counterEmitter, (count) => count * 2)
525
+ ```
526
+
527
+ ### Forgetting type safety
528
+
529
+ ```tsx
530
+ // bad: loose typing
531
+ const emitter = createEmitter('data', {} as any)
532
+
533
+ // good: proper typing
534
+ type DataState = {
535
+ user: User | null
536
+ loading: boolean
537
+ error: Error | null
538
+ }
539
+ const emitter = createEmitter<DataState>('data', {
540
+ user: null,
541
+ loading: false,
542
+ error: null,
543
+ })
544
+ ```
545
+
546
+ ## Summary
547
+
548
+ Emitters are powerful for ephemeral UI state, event coordination, decoupled
549
+ communication, and temporary interactions.
550
+
551
+ But remember:
552
+
553
+ - They're not for persistent state
554
+ - Always clean up listeners
555
+ - Use appropriate comparators
556
+ - Don't overuse them
557
+
558
+ When in doubt, ask yourself:
559
+
560
+ - "Does this state need to persist?" → Use Zero/Jotai
561
+ - "Is this a temporary UI event?" → Use an emitter
562
+ - "Can I use simple React state?" → Use useState