@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 +409 -155
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +9 -7
- package/package.json +5 -9
- package/src/index.ts +13 -7
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
|
+
[](https://github.com/nerdalytics/beacon/blob/trunk/LICENSE)
|
|
6
|
+
[](https://www.npmjs.com/package/@nerdalytics/beacon)
|
|
7
|
+
|
|
8
|
+
[](https://nodejs.org/)
|
|
9
|
+
[](https://typescriptlang.org/)
|
|
10
|
+
[](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
|
-
|
|
14
|
+
<details>
|
|
15
|
+
<summary><Strong>Table of Contents</Strong></summary>
|
|
6
16
|
|
|
7
17
|
- [Features](#features)
|
|
8
|
-
- [
|
|
9
|
-
- [
|
|
10
|
-
- [API](#api)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
60
|
+
- 🪶 **Lightweight** - Zero dependencies
|
|
39
61
|
- ✅ **Node.js compatibility** - Works with Node.js LTS v20+ and v22+
|
|
40
62
|
|
|
41
|
-
##
|
|
63
|
+
## Quick Start
|
|
42
64
|
|
|
43
|
-
```
|
|
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
|
|
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
|
-
//
|
|
57
|
-
|
|
58
|
-
console.log(doubled()); // => 0
|
|
75
|
+
// Create a derived value
|
|
76
|
+
const doubled = derive(() => count() * 2);
|
|
59
77
|
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
82
|
+
// => "Count: 0, Doubled: 0"
|
|
66
83
|
|
|
67
|
-
// Update
|
|
84
|
+
// Update the state - effect runs automatically
|
|
68
85
|
count.set(5);
|
|
69
|
-
// => "Count
|
|
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
|
-
|
|
73
|
-
// =>
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
237
|
+
console.log(`Name: ${nameState()}`);
|
|
88
238
|
});
|
|
89
|
-
// => "Name
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
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
|
-
##
|
|
350
|
+
## Advanced Features
|
|
154
351
|
|
|
155
|
-
|
|
352
|
+
Beacon includes several advanced capabilities that help you build robust applications.
|
|
156
353
|
|
|
157
|
-
|
|
354
|
+
### Infinite Loop Protection
|
|
158
355
|
|
|
159
|
-
|
|
356
|
+
Beacon prevents common mistakes that could cause infinite loops:
|
|
160
357
|
|
|
161
|
-
|
|
358
|
+
```typescript
|
|
359
|
+
import { state, effect } from '@nerdalytics/beacon';
|
|
162
360
|
|
|
163
|
-
|
|
361
|
+
const counter = state(0);
|
|
164
362
|
|
|
165
|
-
|
|
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
|
-
|
|
369
|
+
// Instead, use proper patterns like:
|
|
370
|
+
const increment = () => counter.update(n => n + 1);
|
|
371
|
+
```
|
|
168
372
|
|
|
169
|
-
|
|
373
|
+
### Automatic Cleanup
|
|
170
374
|
|
|
171
|
-
|
|
375
|
+
All subscriptions are automatically cleaned up when effects are unsubscribed:
|
|
172
376
|
|
|
173
|
-
|
|
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
|
-
|
|
394
|
+
// Unsubscribe cleans up everything, including nested effects
|
|
395
|
+
cleanup();
|
|
396
|
+
```
|
|
176
397
|
|
|
177
|
-
|
|
398
|
+
### Custom Equality Functions
|
|
178
399
|
|
|
179
|
-
|
|
400
|
+
Control when subscribers are notified with custom equality checks. You can provide custom equality functions to `state`, `select`, and `protectedState`:
|
|
180
401
|
|
|
181
|
-
|
|
402
|
+
```typescript
|
|
403
|
+
import { state, select, effect, protectedState } from '@nerdalytics/beacon';
|
|
182
404
|
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
npm install
|
|
419
|
+
// Custom equality with select
|
|
420
|
+
const list = state([1, 2, 3]);
|
|
192
421
|
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
|
459
|
+
Beacon actively detects when an effect tries to update a state it depends on, preventing common infinite update cycles:
|
|
228
460
|
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
471
|
+
Beacon employs two complementary strategies for handling cyclical updates:
|
|
240
472
|
|
|
241
|
-
|
|
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
|
-
|
|
476
|
+
## Development
|
|
244
477
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
498
|
+
#### Why "Beacon" Instead of "Signal"?
|
|
255
499
|
|
|
256
|
-
|
|
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
|
-
|
|
502
|
+
#### How does Beacon handle memory management?
|
|
260
503
|
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
Beacon employs two complementary strategies for handling cyclical updates:
|
|
506
|
+
#### Can I use Beacon with Express or other frameworks?
|
|
265
507
|
|
|
266
|
-
|
|
508
|
+
Yes! Beacon works well as a state management solution in any Node.js application:
|
|
267
509
|
|
|
268
|
-
|
|
510
|
+
```typescript
|
|
511
|
+
import express from 'express';
|
|
512
|
+
import { state, effect } from '@nerdalytics/beacon';
|
|
269
513
|
|
|
270
|
-
|
|
514
|
+
const app = express();
|
|
515
|
+
const stats = state({ requests: 0, errors: 0 });
|
|
271
516
|
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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.
|
|
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> =>
|
|
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>(
|
|
59
|
-
|
|
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 (
|
|
152
|
+
if (this.equalityFn(this.value, newValue)) {
|
|
147
153
|
return
|
|
148
154
|
}
|
|
149
155
|
|