@nerdalytics/beacon 1000.1.1 → 1000.2.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/README.md CHANGED
@@ -1,28 +1,50 @@
1
1
  # Beacon <img align="right" src="https://raw.githubusercontent.com/nerdalytics/beacon/refs/heads/trunk/assets/beacon-logo.svg" width="128px" alt="A stylized lighthouse beacon with golden light against a dark blue background, representing the reactive state library"/>
2
2
 
3
+ > Lightweight reactive state management for Node.js backends
4
+
5
+ [![license:mit](https://flat.badgen.net/static/license/MIT/blue)](https://github.com/nerdalytics/beacon/blob/trunk/LICENSE)
6
+ [![registry:npm:version](https://img.shields.io/npm/v/@nerdalytics/beacon.svg)](https://www.npmjs.com/package/@nerdalytics/beacon)
7
+
8
+ [![tech:nodejs](https://img.shields.io/badge/Node%20js-339933?style=for-the-badge&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
9
+ [![language:typescript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white)](https://typescriptlang.org/)
10
+ [![linter:biome](https://img.shields.io/badge/biome-60a5fa?style=for-the-badge&logo=biome&logoColor=white)](https://biomejs.dev/)
11
+
3
12
  A lightweight reactive state library for Node.js backends. Enables reactive state management with automatic dependency tracking and efficient updates for server-side applications.
4
13
 
5
- ## Table of Contents
14
+ <details>
15
+ <summary><Strong>Table of Contents</Strong></summary>
6
16
 
7
17
  - [Features](#features)
8
- - [Installation](#installation)
9
- - [Usage](#usage)
10
- - [API](#api)
11
- - [state](#statetinitialvalue-t-statet)
12
- - [derive](#derivetfn---t-readonlystatet)
13
- - [effect](#effectfn---void---void)
14
- - [batch](#batchtfn---t-t)
15
- - [select](#selectt-rsource-readonlystatet-selectorfn-state-t--r-equalityfn-a-r-b-r--boolean-readonlystater)
16
- - [lens](#lenst-ksource-statet-accessor-state-t--k-statek)
17
- - [readonlyState](#readonlystatetstate-statet-readonlystatet)
18
- - [protectedState](#protectedstatetinitialvalue-t-readonlystatet-writeablestatet)
18
+ - [Quick Start](#quick-start)
19
+ - [Core Concepts](#core-concepts)
20
+ - [API Reference](#api-reference)
21
+ - [Core Primitives](#core-primitives)
22
+ - [state](#statetinitialvalue-t-equalityfn-a-t-b-t--boolean-statet)
23
+ - [derive](#derivetfn---t-readonlystatet)
24
+ - [effect](#effectfn---void---void)
25
+ - [batch](#batchtfn---t-t)
26
+ - [select](#selectt-rsource-readonlystatet-selectorfn-state-t--r-equalityfn-a-r-b-r--boolean-readonlystater)
27
+ - [lens](#lenst-ksource-statet-accessor-state-t--k-statek)
28
+ - [Access Control](#access-control)
29
+ - [readonlyState](#readonlystatetstate-statet-readonlystatet)
30
+ - [protectedState](#protectedstatetinitialvalue-t-equalityfn-a-t-b-t--boolean-readonlystatet-writeablestatet)
31
+ - [Advanced Features](#advanced-features)
32
+ - [Infinite Loop Protection](#infinite-loop-protection)
33
+ - [Automatic Cleanup](#automatic-cleanup)
34
+ - [Custom Equality Functions](#custom-equality-functions)
35
+ - [Design Philosophy](#design-philosophy)
36
+ - [Architecture](#architecture)
19
37
  - [Development](#development)
20
- - [Node.js LTS Compatibility](#nodejs-lts-compatibility)
21
- - [Key Differences vs TC39 Proposal](#key-differences-between-my-library-and-the-tc39-proposal)
22
- - [Implementation Details](#implementation-details)
38
+ - [Key Differences vs TC39 Proposal](#key-differences-vs-tc39-proposal)
23
39
  - [FAQ](#faq)
40
+ - [Why "Beacon" Instead of "Signal"?](#why-beacon-instead-of-signal)
41
+ - [How does Beacon handle memory management?](#how-does-beacon-handle-memory-management)
42
+ - [Can I use Beacon with Express or other frameworks?](#can-i-use-beacon-with-express-or-other-frameworks)
43
+ - [Can Beacon be used in browser applications?](#can-beacon-be-used-in-browser-applications)
24
44
  - [License](#license)
25
45
 
46
+ </details>
47
+
26
48
  ## Features
27
49
 
28
50
  - 📶 **Reactive state** - Create reactive values that automatically track dependencies
@@ -35,67 +57,209 @@ A lightweight reactive state library for Node.js backends. Enables reactive stat
35
57
  - ♻️ **Cycle handling** - Safely manages cyclic dependencies without crashing
36
58
  - 🚨 **Infinite loop detection** - Automatically detects and prevents infinite update loops
37
59
  - 🛠️ **TypeScript-first** - Full TypeScript support with generics
38
- - 🪶 **Lightweight** - Zero dependencies, < 200 LOC
60
+ - 🪶 **Lightweight** - Zero dependencies
39
61
  - ✅ **Node.js compatibility** - Works with Node.js LTS v20+ and v22+
40
62
 
41
- ## Installation
63
+ ## Quick Start
42
64
 
43
- ```sh
44
- npm install @nerdalytics/beacon
65
+ ```other
66
+ npm install @nerdalytics/beacon --save-exact
45
67
  ```
46
68
 
47
- ## Usage
48
-
49
69
  ```typescript
50
- import { state, derive, effect, batch, select, readonlyState, protectedState } from "@nerdalytics/beacon";
70
+ import { state, derive, effect } from '@nerdalytics/beacon';
51
71
 
52
72
  // Create reactive state
53
73
  const count = state(0);
54
- const doubled = derive(() => count() * 2);
55
74
 
56
- // Read values
57
- console.log(count()); // => 0
58
- console.log(doubled()); // => 0
75
+ // Create a derived value
76
+ const doubled = derive(() => count() * 2);
59
77
 
60
- // Setup an effect that automatically runs when dependencies change
61
- // effect() returns a cleanup function that removes all subscriptions when called
62
- const unsubscribe = effect(() => {
63
- console.log(`Count is ${count()}, doubled is ${doubled()}`);
78
+ // Set up an effect
79
+ effect(() => {
80
+ console.log(`Count: ${count()}, Doubled: ${doubled()}`);
64
81
  });
65
- // => "Count is 0, doubled is 0" (effect runs immediately when created)
82
+ // => "Count: 0, Doubled: 0"
66
83
 
67
- // Update values - effect automatically runs after each change
84
+ // Update the state - effect runs automatically
68
85
  count.set(5);
69
- // => "Count is 5, doubled is 10"
86
+ // => "Count: 5, Doubled: 10"
87
+ ```
88
+
89
+ ## Core Concepts
90
+
91
+ Beacon is built around three core primitives:
92
+
93
+ 1. **States**: Mutable, reactive values
94
+ 2. **Derived States**: Read-only computed values that update automatically
95
+ 3. **Effects**: Side effects that run automatically when dependencies change
96
+
97
+ The library handles all the dependency tracking and updates automatically, so you can focus on your business logic.
98
+
99
+ ## API Reference
100
+
101
+ ### Version Compatibility
102
+
103
+ The table below tracks when features were introduced and when function signatures were changed.
104
+
105
+ | API | Introduced | Last Updated | Notes |
106
+ |-----|------------|--------------|-------|
107
+ | `state` | v1.0.0 | v1000.2.0 | Added `equalityFn` parameter |
108
+ | `derive` | v1.0.0 | v1000.0.0 | Renamed `derived` → `derive` |
109
+ | `effect` | v1.0.0 | - | - |
110
+ | `batch` | v1.0.0 | - | - |
111
+ | `select` | v1000.0.0 | - | - |
112
+ | `lens` | v1000.1.0 | - | - |
113
+ | `readonlyState` | v1000.0.0 | - | - |
114
+ | `protectedState` | v1000.0.0 | v1000.2.0 | Added `equalityFn` parameter |
115
+
116
+ ### Core Primitives
117
+
118
+ #### `state<T>(initialValue: T, equalityFn?: (a: T, b: T) => boolean): State<T>`
119
+ > *Since v1.0.0*
120
+
121
+ The foundation of Beacon's reactivity system. Create with `state()` and use like a function.
122
+
123
+ ```typescript
124
+ import { state } from '@nerdalytics/beacon';
125
+
126
+ const counter = state(0);
127
+
128
+ // Read current value
129
+ console.log(counter()); // => 0
130
+
131
+ // Update value
132
+ counter.set(5);
133
+ console.log(counter()); // => 5
70
134
 
71
135
  // Update with a function
72
- count.update((n) => n + 1);
73
- // => "Count is 6, doubled is 12"
136
+ counter.update(n => n + 1);
137
+ console.log(counter()); // => 6
138
+
139
+ // With custom equality function
140
+ const deepCounter = state({ value: 0 }, (a, b) => {
141
+ // Deep equality check
142
+ return a.value === b.value;
143
+ });
144
+
145
+ // This won't trigger effects because values are deeply equal
146
+ deepCounter.set({ value: 0 });
147
+ ```
148
+
149
+ #### `derive<T>(fn: () => T): ReadOnlyState<T>`
150
+ > *Since v1.0.0*
151
+
152
+ Calculate values based on other states. Updates automatically when dependencies change.
153
+
154
+ ```typescript
155
+ import { state, derive } from '@nerdalytics/beacon';
156
+
157
+ const firstName = state('John');
158
+ const lastName = state('Doe');
159
+
160
+ const fullName = derive(() => `${firstName()} ${lastName()}`);
161
+
162
+ console.log(fullName()); // => "John Doe"
163
+
164
+ firstName.set('Jane');
165
+ console.log(fullName()); // => "Jane Doe"
166
+ ```
167
+
168
+ #### `effect(fn: () => void): () => void`
169
+ > *Since v1.0.0*
170
+
171
+ Run side effects when reactive values change.
172
+
173
+ ```typescript
174
+ import { state, effect } from '@nerdalytics/beacon';
175
+
176
+ const user = state({ name: 'Alice', loggedIn: false });
177
+
178
+ const cleanup = effect(() => {
179
+ console.log(`User ${user().name} is ${user().loggedIn ? 'online' : 'offline'}`);
180
+ });
181
+ // => "User Alice is offline" (effect runs immediately when created)
182
+
183
+ user.update(u => ({ ...u, loggedIn: true }));
184
+ // => "User Alice is online"
185
+
186
+ // Stop the effect and clean up all subscriptions
187
+ cleanup();
188
+ ```
189
+
190
+ #### `batch<T>(fn: () => T): T`
191
+ > *Since v1.0.0*
192
+
193
+ Group multiple updates to trigger effects only once.
194
+
195
+ ```typescript
196
+ import { state, effect, batch } from "@nerdalytics/beacon";
197
+
198
+ const count = state(0);
199
+
200
+ effect(() => {
201
+ console.log(`Count is ${count()}`);
202
+ });
203
+ // => "Count is 0" (effect runs immediately)
204
+
205
+ // Without batching, effects run after each update
206
+ count.set(1);
207
+ // => "Count is 1"
208
+ count.set(2);
209
+ // => "Count is 2"
74
210
 
75
211
  // Batch updates (only triggers effects once at the end)
76
212
  batch(() => {
77
213
  count.set(10);
78
214
  count.set(20);
215
+ count.set(30);
79
216
  });
80
- // => "Count is 20, doubled is 40" (only once)
217
+ // => "Count is 30" (only once)
218
+ ```
219
+
220
+ #### `select<T, R>(source: ReadOnlyState<T>, selectorFn: (state: T) => R, equalityFn?: (a: R, b: R) => boolean): ReadOnlyState<R>`
221
+ > *Since v1000.0.0*
81
222
 
82
- // Using select to subscribe to specific parts of state
83
- const user = state({ name: "Alice", age: 30, email: "alice@example.com" });
84
- const nameSelector = select(user, u => u.name);
223
+ Subscribe to specific parts of a state object.
224
+
225
+ ```typescript
226
+ import { state, select, effect } from '@nerdalytics/beacon';
227
+
228
+ const user = state({
229
+ profile: { name: 'Alice' },
230
+ preferences: { theme: 'dark' }
231
+ });
232
+
233
+ // Only triggers when name changes
234
+ const nameState = select(user, u => u.profile.name);
85
235
 
86
236
  effect(() => {
87
- console.log(`Name changed: ${nameSelector()}`);
237
+ console.log(`Name: ${nameState()}`);
88
238
  });
89
- // => "Name changed: Alice"
239
+ // => "Name: Alice"
240
+
241
+ // This triggers the effect
242
+ user.update(u => ({
243
+ ...u,
244
+ profile: { ...u.profile, name: 'Bob' }
245
+ }));
246
+ // => "Name: Bob"
247
+
248
+ // This doesn't trigger the effect (theme changed, not name)
249
+ user.update(u => ({
250
+ ...u,
251
+ preferences: { ...u.preferences, theme: 'light' }
252
+ }));
253
+ ```
90
254
 
91
- // Updates to the selected property will trigger the effect
92
- user.update(u => ({ ...u, name: "Bob" }));
93
- // => "Name changed: Bob"
255
+ #### `lens<T, K>(source: State<T>, accessor: (state: T) => K): State<K>`
256
+ > *Since v1000.1.0*
94
257
 
95
- // Updates to other properties won't trigger the effect
96
- user.update(u => ({ ...u, age: 31 })); // No effect triggered
258
+ Two-way binding to deeply nested properties.
259
+
260
+ ```typescript
261
+ import { state, lens, effect } from "@nerdalytics/beacon";
97
262
 
98
- // Using lens for two-way binding with nested properties
99
263
  const nested = state({
100
264
  user: {
101
265
  profile: {
@@ -118,165 +282,255 @@ themeLens.set("light");
118
282
  console.log(themeLens()); // => "light"
119
283
  console.log(nested().user.profile.settings.theme); // => "light"
120
284
 
121
- // Unsubscribe the effect to stop it from running on future updates
122
- // and clean up all its internal subscriptions
123
- unsubscribe();
285
+ // The entire object is updated with proper referential integrity
286
+ // This makes it easy to detect changes throughout the object tree
287
+ ```
288
+
289
+ ### Access Control
290
+
291
+ Control who can read vs. write to your state.
292
+
293
+ #### `readonlyState<T>(state: State<T>): ReadOnlyState<T>`
294
+ > *Since v1000.0.0*
295
+
296
+ Creates a read-only view of a state, hiding mutation methods. Useful when you want to expose state to other parts of your application without allowing direct mutations.
297
+
298
+ ```typescript
299
+ import { state, readonlyState } from "@nerdalytics/beacon";
124
300
 
125
- // Using readonlyState to create a read-only view
126
301
  const counter = state(0);
127
302
  const readonlyCounter = readonlyState(counter);
128
- // readonlyCounter() works, but readonlyCounter.set() is not available
129
303
 
130
- // Using protectedState to separate read and write capabilities
304
+ // Reading works
305
+ console.log(readonlyCounter()); // => 0
306
+
307
+ // Updating the original state reflects in the readonly view
308
+ counter.set(5);
309
+ console.log(readonlyCounter()); // => 5
310
+
311
+ // This would cause a TypeScript error since readonlyCounter has no set method
312
+ // readonlyCounter.set(10); // Error: Property 'set' does not exist
313
+ ```
314
+
315
+ #### `protectedState<T>(initialValue: T, equalityFn?: (a: T, b: T) => boolean): [ReadOnlyState<T>, WriteableState<T>]`
316
+ > *Since v1000.0.0*
317
+
318
+ Creates a state with separated read and write capabilities, returning a tuple of reader and writer. This pattern allows you to expose only the reading capability to consuming code while keeping the writing capability private.
319
+
320
+ ```typescript
321
+ import { protectedState } from "@nerdalytics/beacon";
322
+
323
+ // Create a state with separated read and write capabilities
131
324
  const [getUser, setUser] = protectedState({ name: 'Alice' });
132
- // getUser() works to read the state
133
- // setUser.set() and setUser.update() work to modify the state
134
- // but getUser has no mutation methods
135
-
136
- // Infinite loop detection example (would throw an error)
137
- try {
138
- effect(() => {
139
- const value = counter();
140
- // The following would throw an error because it attempts to
141
- // update a state that the effect depends on:
142
- // "Infinite loop detected: effect() cannot update a state() it depends on!"
143
- // counter.set(value + 1);
144
-
145
- // Instead, use a safe pattern with proper dependencies:
146
- console.log(`Current counter value: ${value}`);
147
- });
148
- } catch (error) {
149
- console.error('Prevented infinite loop:', error.message);
325
+
326
+ // Read the state
327
+ console.log(getUser()); // => { name: 'Alice' }
328
+
329
+ // Update the state
330
+ setUser.set({ name: 'Bob' });
331
+ console.log(getUser()); // => { name: 'Bob' }
332
+
333
+ // This is useful for exposing only read access to outside consumers
334
+ function createProtectedCounter() {
335
+ const [getCount, setCount] = protectedState(0);
336
+
337
+ return {
338
+ value: getCount,
339
+ increment: () => setCount.update(n => n + 1),
340
+ decrement: () => setCount.update(n => n - 1)
341
+ };
150
342
  }
343
+
344
+ const counter = createProtectedCounter();
345
+ console.log(counter.value()); // => 0
346
+ counter.increment();
347
+ console.log(counter.value()); // => 1
151
348
  ```
152
349
 
153
- ## API
350
+ ## Advanced Features
154
351
 
155
- ### `state<T>(initialValue: T): State<T>`
352
+ Beacon includes several advanced capabilities that help you build robust applications.
156
353
 
157
- Creates a new reactive state container with the provided initial value.
354
+ ### Infinite Loop Protection
158
355
 
159
- ### `derive<T>(fn: () => T): ReadOnlyState<T>`
356
+ Beacon prevents common mistakes that could cause infinite loops:
160
357
 
161
- Creates a read-only computed value that updates when its dependencies change.
358
+ ```typescript
359
+ import { state, effect } from '@nerdalytics/beacon';
162
360
 
163
- ### `effect(fn: () => void): () => void`
361
+ const counter = state(0);
164
362
 
165
- Creates an effect that runs the given function immediately and whenever its dependencies change. Returns an unsubscribe function that stops the effect and cleans up all subscriptions when called.
363
+ // This would throw an error
364
+ effect(() => {
365
+ const value = counter();
366
+ counter.set(value + 1); // Error: Infinite loop detected!
367
+ });
166
368
 
167
- ### `batch<T>(fn: () => T): T`
369
+ // Instead, use proper patterns like:
370
+ const increment = () => counter.update(n => n + 1);
371
+ ```
168
372
 
169
- Batches multiple updates to only trigger effects once at the end.
373
+ ### Automatic Cleanup
170
374
 
171
- ### `select<T, R>(source: ReadOnlyState<T>, selectorFn: (state: T) => R, equalityFn?: (a: R, b: R) => boolean): ReadOnlyState<R>`
375
+ All subscriptions are automatically cleaned up when effects are unsubscribed:
172
376
 
173
- Creates an efficient subscription to a subset of a state value. The selector will only notify its subscribers when the selected value actually changes according to the provided equality function (defaults to `Object.is`).
377
+ ```typescript
378
+ import { state, effect } from '@nerdalytics/beacon';
379
+
380
+ const data = state({ loading: true, items: [] });
381
+
382
+ // Effect with nested effect
383
+ const cleanup = effect(() => {
384
+ if (data().loading) {
385
+ console.log('Loading...');
386
+ } else {
387
+ // This nested effect is automatically cleaned up when the parent is
388
+ effect(() => {
389
+ console.log(`${data().items.length} items loaded`);
390
+ });
391
+ }
392
+ });
174
393
 
175
- ### `lens<T, K>(source: State<T>, accessor: (state: T) => K): State<K>`
394
+ // Unsubscribe cleans up everything, including nested effects
395
+ cleanup();
396
+ ```
176
397
 
177
- Creates a lens for direct updates to nested properties of a state. A lens combines the functionality of `select` (for reading) with the ability to update the nested property while maintaining referential integrity throughout the object tree.
398
+ ### Custom Equality Functions
178
399
 
179
- ### `readonlyState<T>(state: State<T>): ReadOnlyState<T>`
400
+ Control when subscribers are notified with custom equality checks. You can provide custom equality functions to `state`, `select`, and `protectedState`:
180
401
 
181
- Creates a read-only view of a state, hiding mutation methods. Useful when you want to expose a state to other parts of your application without allowing direct mutations.
402
+ ```typescript
403
+ import { state, select, effect, protectedState } from '@nerdalytics/beacon';
182
404
 
183
- ### `protectedState<T>(initialValue: T): [ReadOnlyState<T>, WriteableState<T>]`
405
+ // Custom equality function for state
406
+ // Only trigger updates when references are different (useful for logging)
407
+ const logMessages = state([], (a, b) => a === b); // Reference equality
184
408
 
185
- Creates a state with access control, returning a tuple of reader and writer. This pattern separates read and write capabilities, allowing you to expose only the reading capability to consuming code while keeping the writing capability private.
409
+ // Add logs - each call triggers effects even with identical content
410
+ logMessages.set(['System started']); // Triggers effects
411
+ logMessages.set(['System started']); // Triggers effects again
186
412
 
187
- ## Development
413
+ // Protected state with custom equality function
414
+ const [getConfig, setConfig] = protectedState({ theme: 'dark' }, (a, b) => {
415
+ // Only consider configs equal if all properties match
416
+ return a.theme === b.theme;
417
+ });
188
418
 
189
- ```sh
190
- # Install dependencies
191
- npm install
419
+ // Custom equality with select
420
+ const list = state([1, 2, 3]);
192
421
 
193
- # Run all tests
194
- npm test
422
+ // Only notify when array length changes, not on content changes
423
+ const listLengthState = select(
424
+ list,
425
+ arr => arr.length,
426
+ (a, b) => a === b
427
+ );
195
428
 
196
- # Run all tests with coverage
197
- npm run test:coverage
198
-
199
- # Run specific test suites
200
- # Core functionality
201
- npm run test:unit:state
202
- npm run test:unit:effect
203
- npm run test:unit:derive
204
- npm run test:unit:batch
205
- npm run test:unit:select
206
- npm run test:unit:lens
207
- npm run test:unit:readonly
208
- npm run test:unit:protected
209
-
210
- # Advanced patterns
211
- npm run test:unit:cleanup # Tests for effect cleanup behavior
212
- npm run test:unit:cyclic-dependency # Tests for cyclic dependency handling
213
- npm run test:unit:deep-chain # Tests for deep chain handling
214
- npm run test:unit:infinite-loop # Tests for infinite loop detection
215
-
216
- # Benchmarking
217
- npm run benchmark # Tests for infinite loop detection
218
-
219
- # Format code
220
- npm run format
221
- npm run lint
222
- npm run check # Runs Bioms lint + format
429
+ effect(() => {
430
+ console.log(`List has ${listLengthState()} items`);
431
+ });
223
432
  ```
224
433
 
225
- ### Node.js LTS Compatibility
434
+ ## Design Philosophy
435
+
436
+ Beacon follows these key principles:
437
+
438
+ 1. **Simplicity**: Minimal API surface with powerful primitives
439
+ 2. **Fine-grained reactivity**: Track dependencies at exactly the right level
440
+ 3. **Predictability**: State changes flow predictably through the system
441
+ 4. **Performance**: Optimize for server workloads and memory efficiency
442
+ 5. **Type safety**: Full TypeScript support with generics
443
+
444
+ ## Architecture
445
+
446
+ Beacon is built around a centralized reactivity system with fine-grained dependency tracking. Here's how it works:
447
+
448
+ - **Automatic Dependency Collection**: When a state is read inside an effect, Beacon automatically records this dependency
449
+ - **WeakMap-based Tracking**: Uses WeakMaps for automatic garbage collection
450
+ - **Topological Updates**: Updates flow through the dependency graph in the correct order
451
+ - **Memory-Efficient**: Designed for long-running Node.js processes
452
+
453
+ ### Dependency Tracking
454
+
455
+ When a state is read inside an effect, Beacon automatically records this dependency relationship and sets up a subscription.
456
+
457
+ ### Infinite Loop Prevention
226
458
 
227
- Beacon supports the two most recent Node.js LTS versions (currently v20 and v22). When the package is published to npm, it includes transpiled code compatible with these LTS versions.
459
+ Beacon actively detects when an effect tries to update a state it depends on, preventing common infinite update cycles:
228
460
 
229
- ## Key Differences Between My Library and the [TC39 Proposal][1]
461
+ ```typescript
462
+ // This would throw: "Infinite loop detected"
463
+ effect(() => {
464
+ const value = counter();
465
+ counter.set(value + 1); // Error! Updating a state the effect depends on
466
+ });
467
+ ```
230
468
 
231
- | Aspect | @nerdalytics/beacon | TC39 Proposal |
232
- |--------|---------------------|---------------|
233
- | **API Style** | Functional approach (`state()`, `derive()`) | Class-based design (`Signal.State`, `Signal.Computed`) |
234
- | **Reading/Writing Pattern** | Function call for reading (`count()`), methods for writing (`count.set(5)`) | Method-based access (`get()`/`set()`) |
235
- | **Framework Support** | High-level abstractions like `effect()` and `batch()` | Lower-level primitives (`Signal.subtle.Watcher`) that frameworks build upon |
236
- | **Advanced Features** | Focused on core reactivity | Includes introspection capabilities, watched/unwatched callbacks, and Signal.subtle namespace |
237
- | **Scope and Purpose** | Practical Node.js use cases with minimal API surface | Standardization with robust interoperability between frameworks |
469
+ ### Cyclic Dependencies
238
470
 
239
- ## Implementation Details
471
+ Beacon employs two complementary strategies for handling cyclical updates:
240
472
 
241
- Beacon is designed with a focus on simplicity, performance, and robust handling of complex dependency scenarios.
473
+ 1. **Active Detection**: The system tracks which states an effect reads from and writes to. If an effect attempts to directly update a state it depends on, Beacon throws a clear error.
474
+ 2. **Safe Cycles**: For indirect cycles and safe update patterns, Beacon uses a queue-based update system that won't crash even with cyclical dependencies. When states form a cycle where values eventually stabilize, the system handles these updates efficiently without stack overflows.
242
475
 
243
- ### Key Implementation Concepts
476
+ ## Development
244
477
 
245
- - **Fine-grained reactivity**: Dependencies are tracked automatically at the state level
246
- - **Efficient updates**: Changes only propagate to affected parts of the dependency graph
247
- - **Cyclical dependency handling**: Robust handling of circular references without crashing
248
- - **Infinite loop detection**: Safeguards against direct self-mutation within effects
249
- - **Memory management**: Automatic cleanup of subscriptions when effects are disposed
250
- - **Optimized batching**: Smart scheduling of updates to minimize redundant computations
478
+ ```other
479
+ # Install dependencies
480
+ npm install
481
+
482
+ # Run tests
483
+ npm test
484
+ ```
485
+
486
+ ## Key Differences vs [TC39 Proposal][1]
487
+
488
+ | **Aspect** | **@nerdalytics/beacon** | **TC39 Proposal** |
489
+ | --------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
490
+ | **API Style** | Functional approach (`state()`, `derive()`) | Class-based design (`Signal.State`, `Signal.Computed`) |
491
+ | **Reading/Writing Pattern** | Function call for reading (`count()`), methods for writing (`count.set(5)`) | Method-based access (`get()`/`set()`) |
492
+ | **Framework Support** | High-level abstractions like `effect()` and `batch()` | Lower-level primitives (`Signal.subtle.Watcher`) that frameworks build upon |
493
+ | **Advanced Features** | Focused on core reactivity | Includes introspection capabilities, watched/unwatched callbacks, and Signal.subtle namespace |
494
+ | **Scope and Purpose** | Practical Node.js use cases with minimal API surface | Standardization with robust interoperability between frameworks |
251
495
 
252
496
  ## FAQ
253
497
 
254
- <details>
498
+ #### Why "Beacon" Instead of "Signal"?
255
499
 
256
- <summary>Why "Beacon" Instead of "Signal"?</summary>
257
- I chose "Beacon" because it clearly represents how the library broadcasts notifications when state changes—just like a lighthouse guides ships. While my library draws inspiration from Preact Signals, Angular Signals, and aspects of Svelte, I wanted to create something lighter and specifically designed for Node.js backends. Using "Beacon" instead of the term "Signal" helps avoid confusion with the TC39 proposal and similar libraries while still accurately describing the core functionality.
500
+ Beacon represents how the library broadcasts notifications when state changes—just like a lighthouse guides ships. The name avoids confusion with the TC39 proposal and similar libraries while accurately describing the core functionality.
258
501
 
259
- </details>
502
+ #### How does Beacon handle memory management?
260
503
 
261
- <details>
504
+ Beacon uses WeakMaps for dependency tracking, ensuring that unused states and effects can be garbage collected. When you unsubscribe an effect, all its internal subscriptions are automatically cleaned up.
262
505
 
263
- <summary>How does Beacon handle infinite update cycles?</summary>
264
- Beacon employs two complementary strategies for handling cyclical updates:
506
+ #### Can I use Beacon with Express or other frameworks?
265
507
 
266
- 1. **Infinite Loop Detection**: Beacon actively detects direct infinite loops in effects by tracking which states an effect reads and writes to. If an effect attempts to update a state it depends on (directly modifying its own dependency), Beacon throws an error with a clear message: "Infinite loop detected: effect() cannot update a state() it depends on!"
508
+ Yes! Beacon works well as a state management solution in any Node.js application:
267
509
 
268
- 2. **Safe Cyclic Dependencies**: For indirect cycles and safe update patterns, Beacon uses a queue-based update system that won't crash even with cyclical dependencies. When states form a cycle where values eventually stabilize, the system handles these updates efficiently without stack overflows.
510
+ ```typescript
511
+ import express from 'express';
512
+ import { state, effect } from '@nerdalytics/beacon';
269
513
 
270
- This dual approach prevents accidental infinite loops while still supporting legitimate cyclic update patterns that eventually stabilize.
514
+ const app = express();
515
+ const stats = state({ requests: 0, errors: 0 });
271
516
 
272
- </details>
517
+ // Update stats on each request
518
+ app.use((req, res, next) => {
519
+ stats.update(s => ({ ...s, requests: s.requests + 1 }));
520
+ next();
521
+ });
273
522
 
274
- <details>
523
+ // Log stats every minute
524
+ effect(() => {
525
+ console.log(`Stats: ${stats().requests} requests, ${stats().errors} errors`);
526
+ });
527
+
528
+ app.listen(3000);
529
+ ```
275
530
 
276
- <summary>How performant is Beacon?</summary>
277
- Beacon is designed with performance in mind for server-side Node.js environments. It achieves millions of operations per second for core operations like reading and writing states.
531
+ #### Can Beacon be used in browser applications?
278
532
 
279
- </details>
533
+ While Beacon is optimized for Node.js server-side applications, its core principles would work in browser environments. However, the library is specifically designed for backend use cases and hasn't been optimized for browser bundle sizes or DOM integration patterns.
280
534
 
281
535
  ## License
282
536
 
@@ -11,7 +11,7 @@ export type State<T> = ReadOnlyState<T> & WriteableState<T> & {
11
11
  /**
12
12
  * Creates a reactive state container with the provided initial value.
13
13
  */
14
- export declare const state: <T>(initialValue: T) => State<T>;
14
+ export declare const state: <T>(initialValue: T, equalityFn?: (a: T, b: T) => boolean) => State<T>;
15
15
  /**
16
16
  * Registers a function to run whenever its reactive dependencies change.
17
17
  */
@@ -35,7 +35,7 @@ export declare const readonlyState: <T>(state: State<T>) => ReadOnlyState<T>;
35
35
  /**
36
36
  * Creates a state with access control, returning a tuple of reader and writer.
37
37
  */
38
- export declare const protectedState: <T>(initialValue: T) => [ReadOnlyState<T>, WriteableState<T>];
38
+ export declare const protectedState: <T>(initialValue: T, equalityFn?: (a: T, b: T) => boolean) => [ReadOnlyState<T>, WriteableState<T>];
39
39
  /**
40
40
  * Creates a lens for direct updates to nested properties of a state.
41
41
  */
package/dist/src/index.js CHANGED
@@ -3,7 +3,7 @@ const STATE_ID = Symbol();
3
3
  /**
4
4
  * Creates a reactive state container with the provided initial value.
5
5
  */
6
- export const state = (initialValue) => StateImpl.createState(initialValue);
6
+ export const state = (initialValue, equalityFn = Object.is) => StateImpl.createState(initialValue, equalityFn);
7
7
  /**
8
8
  * Registers a function to run whenever its reactive dependencies change.
9
9
  */
@@ -27,8 +27,8 @@ export const readonlyState = (state) => () => state();
27
27
  /**
28
28
  * Creates a state with access control, returning a tuple of reader and writer.
29
29
  */
30
- export const protectedState = (initialValue) => {
31
- const fullState = state(initialValue);
30
+ export const protectedState = (initialValue, equalityFn = Object.is) => {
31
+ const fullState = state(initialValue, equalityFn);
32
32
  return [
33
33
  () => readonlyState(fullState)(),
34
34
  {
@@ -60,15 +60,17 @@ class StateImpl {
60
60
  value;
61
61
  subscribers = new Set();
62
62
  stateId = Symbol();
63
- constructor(initialValue) {
63
+ equalityFn;
64
+ constructor(initialValue, equalityFn = Object.is) {
64
65
  this.value = initialValue;
66
+ this.equalityFn = equalityFn;
65
67
  }
66
68
  /**
67
69
  * Creates a reactive state container with the provided initial value.
68
70
  * Implementation of the public 'state' function.
69
71
  */
70
- static createState = (initialValue) => {
71
- const instance = new StateImpl(initialValue);
72
+ static createState = (initialValue, equalityFn = Object.is) => {
73
+ const instance = new StateImpl(initialValue, equalityFn);
72
74
  const get = () => instance.get();
73
75
  get.set = (value) => instance.set(value);
74
76
  get.update = (fn) => instance.update(fn);
@@ -104,7 +106,7 @@ class StateImpl {
104
106
  // Handles value updates with built-in optimizations and safeguards
105
107
  set = (newValue) => {
106
108
  // Skip updates for unchanged values to prevent redundant effect executions
107
- if (Object.is(this.value, newValue)) {
109
+ if (this.equalityFn(this.value, newValue)) {
108
110
  return;
109
111
  }
110
112
  // Infinite loop detection prevents direct self-mutation within effects,
package/package.json CHANGED
@@ -1,16 +1,11 @@
1
1
  {
2
2
  "name": "@nerdalytics/beacon",
3
- "version": "1000.1.1",
3
+ "version": "1000.2.0",
4
4
  "description": "A lightweight reactive state library for Node.js backends. Enables reactive state management with automatic dependency tracking and efficient updates for server-side applications.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
7
7
  "types": "dist/src/index.d.ts",
8
- "files": [
9
- "dist/src/index.js",
10
- "dist/src/index.d.ts",
11
- "src/index.ts",
12
- "LICENSE"
13
- ],
8
+ "files": ["dist/src/index.js", "dist/src/index.d.ts", "src/index.ts", "LICENSE"],
14
9
  "repository": {
15
10
  "url": "git+https://github.com/nerdalytics/beacon.git",
16
11
  "type": "git"
@@ -34,6 +29,7 @@
34
29
  "test:unit:cyclic-dependency": "node --test tests/cyclic-dependency.test.ts",
35
30
  "test:unit:deep-chain": "node --test tests/deep-chain.test.ts",
36
31
  "test:unit:infinite-loop": "node --test tests/infinite-loop.test.ts",
32
+ "test:unit:custom-equality": "node --test tests/custom-equality.test.ts",
37
33
  "benchmark": "node scripts/benchmark.ts",
38
34
  "build": "npm run build:lts",
39
35
  "prebuild:lts": "rm -rf dist/",
@@ -66,11 +62,11 @@
66
62
  "license": "MIT",
67
63
  "devDependencies": {
68
64
  "@biomejs/biome": "1.9.4",
69
- "@types/node": "22.14.0",
65
+ "@types/node": "22.14.1",
70
66
  "typescript": "5.8.3"
71
67
  },
72
68
  "engines": {
73
69
  "node": ">=20.0.0"
74
70
  },
75
- "packageManager": "npm@11.2.0"
71
+ "packageManager": "npm@11.3.0"
76
72
  }
package/src/index.ts CHANGED
@@ -18,7 +18,8 @@ export type State<T> = ReadOnlyState<T> &
18
18
  /**
19
19
  * Creates a reactive state container with the provided initial value.
20
20
  */
21
- export const state = <T>(initialValue: T): State<T> => StateImpl.createState(initialValue)
21
+ export const state = <T>(initialValue: T, equalityFn: (a: T, b: T) => boolean = Object.is): State<T> =>
22
+ StateImpl.createState(initialValue, equalityFn)
22
23
 
23
24
  /**
24
25
  * Registers a function to run whenever its reactive dependencies change.
@@ -55,8 +56,11 @@ export const readonlyState =
55
56
  /**
56
57
  * Creates a state with access control, returning a tuple of reader and writer.
57
58
  */
58
- export const protectedState = <T>(initialValue: T): [ReadOnlyState<T>, WriteableState<T>] => {
59
- const fullState = state(initialValue)
59
+ export const protectedState = <T>(
60
+ initialValue: T,
61
+ equalityFn: (a: T, b: T) => boolean = Object.is
62
+ ): [ReadOnlyState<T>, WriteableState<T>] => {
63
+ const fullState = state(initialValue, equalityFn)
60
64
  return [
61
65
  (): T => readonlyState(fullState)(),
62
66
  {
@@ -93,17 +97,19 @@ class StateImpl<T> {
93
97
  private value: T
94
98
  private subscribers = new Set<Subscriber>()
95
99
  private stateId = Symbol()
100
+ private equalityFn: (a: T, b: T) => boolean
96
101
 
97
- constructor(initialValue: T) {
102
+ constructor(initialValue: T, equalityFn: (a: T, b: T) => boolean = Object.is) {
98
103
  this.value = initialValue
104
+ this.equalityFn = equalityFn
99
105
  }
100
106
 
101
107
  /**
102
108
  * Creates a reactive state container with the provided initial value.
103
109
  * Implementation of the public 'state' function.
104
110
  */
105
- static createState = <T>(initialValue: T): State<T> => {
106
- const instance = new StateImpl<T>(initialValue)
111
+ static createState = <T>(initialValue: T, equalityFn: (a: T, b: T) => boolean = Object.is): State<T> => {
112
+ const instance = new StateImpl<T>(initialValue, equalityFn)
107
113
  const get = (): T => instance.get()
108
114
  get.set = (value: T): void => instance.set(value)
109
115
  get.update = (fn: (currentValue: T) => T): void => instance.update(fn)
@@ -143,7 +149,7 @@ class StateImpl<T> {
143
149
  // Handles value updates with built-in optimizations and safeguards
144
150
  set = (newValue: T): void => {
145
151
  // Skip updates for unchanged values to prevent redundant effect executions
146
- if (Object.is(this.value, newValue)) {
152
+ if (this.equalityFn(this.value, newValue)) {
147
153
  return
148
154
  }
149
155