@slimlib/store 1.6.2 → 2.0.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.
Files changed (57) hide show
  1. package/README.md +700 -129
  2. package/dist/index.mjs +1067 -1
  3. package/dist/index.mjs.map +1 -0
  4. package/package.json +9 -69
  5. package/src/computed.ts +232 -0
  6. package/src/core.ts +434 -0
  7. package/src/debug.ts +115 -0
  8. package/src/effect.ts +125 -0
  9. package/src/flags.ts +38 -0
  10. package/src/globals.ts +30 -0
  11. package/src/index.ts +9 -0
  12. package/src/internal-types.ts +45 -0
  13. package/src/scope.ts +85 -0
  14. package/src/signal.ts +55 -0
  15. package/src/state.ts +170 -0
  16. package/src/symbols.ts +9 -0
  17. package/src/types.ts +47 -0
  18. package/types/index.d.ts +129 -0
  19. package/types/index.d.ts.map +52 -0
  20. package/angular/package.json +0 -5
  21. package/core/package.json +0 -5
  22. package/dist/angular.cjs +0 -37
  23. package/dist/angular.d.ts +0 -23
  24. package/dist/angular.mjs +0 -33
  25. package/dist/angular.umd.js +0 -2
  26. package/dist/angular.umd.js.map +0 -1
  27. package/dist/core.cjs +0 -79
  28. package/dist/core.d.ts +0 -8
  29. package/dist/core.mjs +0 -76
  30. package/dist/index.cjs +0 -8
  31. package/dist/index.d.ts +0 -1
  32. package/dist/index.umd.js +0 -2
  33. package/dist/index.umd.js.map +0 -1
  34. package/dist/preact.cjs +0 -16
  35. package/dist/preact.d.ts +0 -3
  36. package/dist/preact.mjs +0 -13
  37. package/dist/preact.umd.js +0 -2
  38. package/dist/preact.umd.js.map +0 -1
  39. package/dist/react.cjs +0 -16
  40. package/dist/react.d.ts +0 -3
  41. package/dist/react.mjs +0 -13
  42. package/dist/react.umd.js +0 -2
  43. package/dist/react.umd.js.map +0 -1
  44. package/dist/rxjs.cjs +0 -18
  45. package/dist/rxjs.d.ts +0 -3
  46. package/dist/rxjs.mjs +0 -15
  47. package/dist/rxjs.umd.js +0 -2
  48. package/dist/rxjs.umd.js.map +0 -1
  49. package/dist/svelte.cjs +0 -7
  50. package/dist/svelte.d.ts +0 -1
  51. package/dist/svelte.mjs +0 -5
  52. package/dist/svelte.umd.js +0 -2
  53. package/dist/svelte.umd.js.map +0 -1
  54. package/preact/package.json +0 -5
  55. package/react/package.json +0 -5
  56. package/rxjs/package.json +0 -5
  57. package/svelte/package.json +0 -5
package/README.md CHANGED
@@ -1,217 +1,788 @@
1
1
  # Store
2
2
 
3
- Proxy-based store for SPAs.
3
+ Reactive state management for SPAs with automatic dependency tracking.
4
4
 
5
- 1. Simple
6
- 2. Relatively fast
7
- 3. Small size (less than 1Kb minified not gzipped)
8
- 4. Typescript support
5
+ ## Why This Library
6
+
7
+ It is mostly DX. I don't want mandatory scopes, lifetime management, explicit batches, or non-tree-shakable code in my apps, and that's what I provide.
8
+
9
+ It also works really well with imperative code because the state primitive is Proxy-based (at the same time it works with native objects like Set or Map without any wrappers, a wrapper will be more optimized, but it is not required).
10
+
11
+ - **Relatively small and tree shakable** - less than 5KiB minified, you pay only for what you use
12
+ - **GC friendly** - all primitives can be garbage collected
13
+ - **Easy batching** - by default it is batched to the next microtask, but it is configurable
14
+ - **Relatively fast** - it is not faster than Alien signals but faster than other big frameworks (it truly depends on the scenario)
15
+ - **Proxy-based state** - easy to understand imperative code, signal alternative is also provided
16
+ - **Dev mode** - you get warnings when you do stupid things
9
17
 
10
18
  [Changelog](./CHANGELOG.md)
11
19
 
12
- # Installation
20
+ ## Installation
13
21
 
14
- Using npm:
22
+ ```bash
23
+ npm install @slimlib/store
15
24
  ```
16
- npm install --save-dev @slimlib/store
25
+
26
+ ## Quick Start
27
+
28
+ ```js
29
+ import { state, effect, computed } from "@slimlib/store";
30
+
31
+ // Create a reactive store
32
+ const store = state({ count: 0, name: "test" });
33
+
34
+ // Effects automatically track dependencies and re-run when they change
35
+ const dispose = effect(() => {
36
+ console.log("Count:", store.count);
37
+ });
38
+ // Logs: "Count: 0" (on next microtask)
39
+
40
+ // Computed values are lazy and cached
41
+ const doubled = computed(() => store.count * 2);
42
+
43
+ // Updates trigger effects automatically
44
+ store.count = 5;
45
+ // Logs: "Count: 5" (on next microtask)
46
+
47
+ console.log(doubled()); // 10
48
+
49
+ // Stop the effect when done
50
+ dispose();
17
51
  ```
18
52
 
19
- # Usage
53
+ ## API
20
54
 
21
- ### React
55
+ ### Reactive Primitives
22
56
 
23
- ```javascript
24
- import { createStore, useStore } from '@slimlib/store/react';
57
+ #### `state<T>(object?: T): T`
25
58
 
26
- // create store
27
- const [state, store] = createStore();
59
+ Creates a reactive store from an object. Returns a proxy that tracks property access for dependency tracking.
28
60
 
29
- // action
30
- function doSomething() {
31
- state.field = value;
32
- }
61
+ ```js
62
+ const store = state({ user: { name: "John" }, items: [] });
33
63
 
34
- //component
35
- function Component() {
36
- const state = useStore(store);
37
-
38
- // use state
39
- }
64
+ store.user.name = "Jane"; // Triggers effects that depend on user.name
65
+ store.items.push("item"); // Triggers effects that depend on items
40
66
  ```
41
67
 
42
- ### Preact
68
+ #### `signal<T>(initialValue?: T): (() => T) & { set: (value: T) => void }`
43
69
 
44
- ```javascript
45
- import { createStore, useStore } from '@slimlib/store/preact';
70
+ Creates a simple reactive signal. Returns a function to read the value with a `set` method to update it.
46
71
 
47
- // create store
48
- const [state, store] = createStore();
72
+ ```js
73
+ import { signal, effect } from "@slimlib/store";
49
74
 
50
- // action
51
- function doSomething() {
52
- state.field = value;
53
- }
75
+ const count = signal(0);
54
76
 
55
- //component
56
- function Component() {
57
- const state = useStore(store);
58
-
59
- // use state
60
- }
77
+ effect(() => {
78
+ console.log("Count:", count());
79
+ });
80
+
81
+ count.set(5); // Effect runs after microtask
82
+ console.log(count()); // 5
61
83
  ```
62
84
 
63
- ### Svelte
85
+ #### `computed<T>(getter: () => T, equals?: (a: T, b: T) => boolean): () => T`
64
86
 
65
- In store
87
+ Creates a computed value that is lazily evaluated and cached until dependencies change. Returns a function that retrieves the computed value.
66
88
 
67
- ```javascript
68
- import { createStore } from '@slimlib/store/svelte';
89
+ - `getter` - Function that computes the value
90
+ - `equals` - Optional equality function (defaults to `Object.is`). Return `true` if values are equal (skip update).
69
91
 
70
- // create store
71
- const [state, subscribe] = createStore();
92
+ ```js
93
+ const store = state({ items: [1, 2, 3] });
72
94
 
73
- // action
74
- export function doSomething() {
75
- state.field = value;
76
- }
95
+ const sum = computed(() => store.items.reduce((a, b) => a + b, 0));
96
+ const doubled = computed(() => sum() * 2);
97
+
98
+ console.log(doubled()); // 12
77
99
 
78
- export default { subscribe };
100
+ store.items.push(4);
101
+ console.log(doubled()); // 20
79
102
  ```
80
103
 
81
- In component
104
+ ##### Reactive vs Imperative Usage
105
+
106
+ Computeds support two usage patterns:
82
107
 
83
- ```svelte
84
- <script>
85
- import { storeName } from './stores/storeName';
86
- </script>
108
+ **Reactive** - tracked by effects, automatically re-evaluated:
87
109
 
88
- // use it in reactive way for reading data
89
- $storeName
110
+ ```js
111
+ const count = signal(0);
112
+ const doubled = computed(() => count() * 2);
113
+
114
+ effect(() => {
115
+ console.log(doubled()); // Re-runs when count changes
116
+ });
90
117
  ```
91
118
 
92
- ### Angular
119
+ **Imperative** - called directly from regular code on-demand:
93
120
 
94
- In store
121
+ ```js
122
+ const count = signal(0);
123
+ const doubled = computed(() => count() * 2);
95
124
 
96
- ```javascript
97
- import { SlimlibStore } from '@slimlib/store/angular';
125
+ // No effect needed - just read when you want
126
+ console.log(doubled()); // 0
98
127
 
99
- // create store
100
- @Injectable()
101
- export class StoreName extends SlimlibStore<State> {
102
- constructor() {
103
- super(/*Initial state*/{ field: 123 }});
104
- }
128
+ count.set(5);
129
+ console.log(doubled()); // 10 - recomputes on demand
130
+ ```
105
131
 
106
- // selectors
107
- field = this.select(state => state.field);
132
+ Both patterns can coexist. A computed stays connected to its sources as long as it's referenced, regardless of whether any effect tracks it. This allows computeds to be used as derived getters in imperative code while still participating in the reactive graph when needed.
108
133
 
109
- // actions
110
- doSomething() {
111
- this.state.field = value;
112
- }
113
- }
134
+ #### `effect(callback: () => void | EffectCleanup): () => void`
135
+
136
+ Creates a reactive effect that runs when its dependencies change. Returns a dispose function.
137
+
138
+ - `callback` - Effect function that optionally returns a cleanup function (`EffectCleanup = () => void`)
139
+ - Effects run on the next microtask (not synchronously) by default
140
+ - Multiple synchronous changes are automatically batched
141
+ - The cleanup function runs before each re-execution and when the effect is disposed
142
+ - **Important**: If not created within a scope, you must hold a reference to the dispose function to prevent the effect from being garbage collected
143
+
144
+ ```js
145
+ import { effect, state } from "@slimlib/store";
146
+
147
+ const store = state({ count: 0 });
148
+
149
+ // Hold the dispose function to prevent GC
150
+ const dispose = effect(() => {
151
+ console.log(store.count);
152
+
153
+ // Optional: return cleanup function
154
+ return () => {
155
+ console.log("Cleaning up...");
156
+ };
157
+ });
158
+
159
+ store.count = 1; // Effect runs after microtask
160
+
161
+ dispose(); // Stop the effect, run cleanup
114
162
  ```
115
163
 
116
- ### rxjs
164
+ For managing multiple effects, use a `scope`:
117
165
 
118
- ```javascript
119
- import { createStore, toObservable } from '@slimlib/store/rxjs';
166
+ ```js
167
+ import { scope, effect, state } from "@slimlib/store";
120
168
 
121
- // create store
122
- const [state, store] = createStore();
169
+ const store = state({ count: 0 });
123
170
 
124
- // action
125
- export function doSomething() {
126
- state.field = value;
127
- }
171
+ const ctx = scope(() => {
172
+ effect(() => console.log("Effect 1:", store.count));
173
+ effect(() => console.log("Effect 2:", store.count));
174
+ });
128
175
 
129
- // observable
130
- export const state$ = toObservable(store);
176
+ ctx(); // Dispose all effects at once
131
177
  ```
132
178
 
133
- ## API
179
+ ### Scope Management
180
+
181
+ #### `scope(callback?, parent?): Scope`
182
+
183
+ Creates a reactive scope for tracking effects. Effects created within a scope are automatically tracked and disposed together when the scope is disposed. This is useful for managing component lifecycles or grouping related effects.
184
+
185
+ ```js
186
+ import { scope, effect, state } from "@slimlib/store";
187
+
188
+ const store = state({ count: 0 });
189
+
190
+ // Create a scope with callback
191
+ const ctx = scope((onDispose) => {
192
+ effect(() => console.log(store.count));
193
+
194
+ // Register cleanup to run when scope is disposed
195
+ onDispose(() => console.log("Scope disposed"));
196
+ });
197
+
198
+ // Extend the scope (add more effects)
199
+ ctx((onDispose) => {
200
+ effect(() => console.log("Another effect:", store.count));
201
+ });
202
+
203
+ // Dispose all effects and run cleanup handlers
204
+ ctx();
205
+ ```
206
+
207
+ **Parameters:**
208
+
209
+ - `callback` - Optional function receiving an `onDispose` callback for registering cleanup handlers
210
+ - `parent` - Optional parent scope (defaults to `activeScope`). Pass `undefined` for a detached scope with no parent.
211
+
212
+ **Returns:** A scope function (`ctx`) that:
213
+
214
+ - `ctx(callback)` - Runs callback in scope context, returns `ctx` for chaining
215
+ - `ctx()` - Disposes scope and all tracked effects, returns `undefined`
216
+
217
+ **Note:** All operations on a disposed scope are safe no-ops. Disposing multiple times, extending, or registering cleanup handlers after disposal will silently do nothing. Cleanup handlers only run once.
218
+
219
+ ##### Hierarchical Scopes
220
+
221
+ Scopes can be nested. When a parent scope is disposed, all child scopes are also disposed:
222
+
223
+ ```js
224
+ const outer = scope(() => {
225
+ effect(() => console.log("Outer effect"));
226
+
227
+ // Inner scope automatically becomes child of outer
228
+ const inner = scope(() => {
229
+ effect(() => console.log("Inner effect"));
230
+ });
231
+ });
134
232
 
135
- ### `main` and `core` exports
233
+ outer(); // Disposes both outer AND inner effects
234
+ ```
235
+
236
+ Create a detached scope (no parent) by passing `undefined`:
237
+
238
+ ```js
239
+ const detached = scope(() => {
240
+ effect(() => console.log("Detached"));
241
+ }, undefined);
242
+ ```
243
+
244
+ #### `activeScope`
245
+
246
+ A live binding export that contains the currently active scope (or `undefined` if none).
247
+
248
+ ```js
249
+ import { activeScope, scope } from "@slimlib/store";
250
+
251
+ console.log(activeScope); // undefined
252
+
253
+ scope(() => {
254
+ console.log(activeScope); // the current scope
255
+ });
256
+
257
+ console.log(activeScope); // undefined
258
+ ```
259
+
260
+ #### `setActiveScope(scope?): void`
261
+
262
+ Sets or clears the global active scope. Effects created outside of a `scope()` callback will be tracked to the active scope.
136
263
 
137
- #### `createStoreFactory(notifyAfterCreation: boolean)`
264
+ ```js
265
+ import { setActiveScope, scope, effect, state } from "@slimlib/store";
138
266
 
139
- Returns createStore factory (see next) which notifies immediately after creating store if `notifyAfterCreation` is truthy.
267
+ const store = state({ count: 0 });
268
+ const appScope = scope();
140
269
 
141
- #### `createStore<T>(initialState: T): [T, Store<T>, () => void]`
270
+ // Set as the default scope for all effects
271
+ setActiveScope(appScope);
272
+
273
+ // This effect is tracked to appScope
274
+ effect(() => console.log(store.count));
275
+
276
+ // Clear the active scope
277
+ setActiveScope(undefined);
278
+
279
+ // Dispose all effects
280
+ appScope();
281
+ ```
282
+
283
+ This is useful for frameworks that want a single root scope for all effects created during component initialization.
284
+
285
+ ### Scheduling and Execution
286
+
287
+ #### `flushEffects(): void`
288
+
289
+ Immediately executes all pending effects without waiting for the next microtask. Useful for testing or when you need synchronous effect execution.
290
+
291
+ ```js
292
+ const store = state({ count: 0 });
293
+
294
+ let runs = 0;
295
+ effect(() => {
296
+ store.count;
297
+ runs++;
298
+ });
299
+
300
+ flushEffects(); // runs = 1 (initial run)
301
+
302
+ store.count = 1;
303
+ store.count = 2;
304
+ flushEffects(); // runs = 2 (batched update executed immediately)
305
+ ```
306
+
307
+ #### `setScheduler(fn: (callback: () => void) => void): void`
308
+
309
+ Sets a custom scheduler function for effect execution. By default, effects are scheduled using `queueMicrotask`. You can replace it with any function that accepts a callback.
310
+
311
+ ```js
312
+ import { setScheduler } from "@slimlib/store";
313
+
314
+ // Use setTimeout instead of queueMicrotask
315
+ setScheduler((callback) => setTimeout(callback, 0));
316
+
317
+ // Or use requestAnimationFrame for UI updates
318
+ setScheduler((callback) => requestAnimationFrame(callback));
319
+ ```
320
+
321
+ ### Utilities
322
+
323
+ #### `untracked<T>(callback: () => T): T`
324
+
325
+ Execute a callback without tracking dependencies.
326
+
327
+ ```js
328
+ const store = state({ a: 1, b: 2 });
329
+
330
+ effect(() => {
331
+ console.log(store.a); // Tracked - effect re-runs when a changes
332
+
333
+ const b = untracked(() => store.b); // Not tracked
334
+ console.log(b);
335
+ });
336
+
337
+ store.b = 10; // Effect does NOT re-run
338
+ store.a = 5; // Effect re-runs
339
+ ```
340
+
341
+ #### `unwrapValue<T>(value: T): T`
342
+
343
+ Gets the underlying raw object from a proxy.
344
+
345
+ ```js
346
+ const store = state({ data: { x: 1 } });
347
+ const raw = unwrapValue(store); // Returns the original object
348
+ ```
349
+
350
+ ### Debug Configuration
351
+
352
+ #### `debugConfig(flags: number): void`
353
+
354
+ Configure debug behavior using a bitfield of flags.
355
+
356
+ ```js
357
+ import { debugConfig, WARN_ON_WRITE_IN_COMPUTED } from "@slimlib/store";
358
+
359
+ // Enable warnings when writing to signals/state inside a computed
360
+ debugConfig(WARN_ON_WRITE_IN_COMPUTED);
361
+
362
+ // Disable all debug flags
363
+ debugConfig(0);
364
+ ```
142
365
 
143
- Store factory function that takes initial state and returns proxy object, store and function to notify subscribers. Proxy object meant to be left for actions implementations, store is for subscription for changes and notification only for some edge cases when an original object has been changed and listeners have to be notified.
366
+ ##### `WARN_ON_WRITE_IN_COMPUTED`
144
367
 
145
- #### `unwrapValue(value: T): T`
368
+ When enabled, logs a warning to the console if you write to a signal or state inside a computed. This helps catch a common mistake where the computed will not re-run when the written value changes, potentially leading to stale values.
146
369
 
147
- Unwraps a potential proxy object and returns a plain object if possible or value itself.
370
+ ```js
371
+ import { debugConfig, WARN_ON_WRITE_IN_COMPUTED } from "@slimlib/store";
148
372
 
149
- #### `Store<T>`
373
+ debugConfig(WARN_ON_WRITE_IN_COMPUTED);
150
374
 
151
- ```typescript
152
- type StoreCallback<T> = (value: T) => void;
153
- type UnsubscribeCallback = () => void;
154
- interface Store<T> {
155
- (cb: StoreCallback<T>): UnsubscribeCallback;
156
- (): T;
375
+ const counter = signal(0);
376
+ const other = signal(0);
377
+
378
+ const doubled = computed(() => {
379
+ other.set(counter() * 2); // ⚠️ Warning logged!
380
+ return counter() * 2;
381
+ });
382
+ ```
383
+
384
+ **Note**: This warning only appears in development mode (when `esm-env`'s `DEV` flag is true). In production builds, the warning code is completely eliminated via dead code elimination when bundlers replace the `DEV` constant with `false`.
385
+
386
+ For zero-cost production builds, configure your bundler to replace the `DEV` constant. With Vite/Rollup, this happens automatically based on the build mode.
387
+
388
+ ##### `SUPPRESS_EFFECT_GC_WARNING`
389
+
390
+ By default in development mode, the library warns when an effect is garbage collected without being properly disposed. This helps detect memory leaks where effects are created but never cleaned up.
391
+
392
+ ```js
393
+ // ⚠️ This will trigger a warning in dev mode:
394
+ (() => {
395
+ const store = state({ count: 0 });
396
+ effect(() => {
397
+ console.log(store.count);
398
+ });
399
+ // dispose function is not stored or called!
400
+ })();
401
+ // When the scope exits, the effect's dispose function becomes unreachable
402
+ // and will be garbage collected, triggering a warning.
403
+ ```
404
+
405
+ The warning includes a stack trace showing where the orphaned effect was created, making it easy to track down the issue.
406
+
407
+ To suppress this warning (e.g., in tests or when intentionally letting effects be GC'd), use the `SUPPRESS_EFFECT_GC_WARNING` flag:
408
+
409
+ ```js
410
+ import { debugConfig, SUPPRESS_EFFECT_GC_WARNING } from "@slimlib/store";
411
+
412
+ // Suppress the GC warning
413
+ debugConfig(SUPPRESS_EFFECT_GC_WARNING);
414
+
415
+ // Combine with other flags
416
+ debugConfig(WARN_ON_WRITE_IN_COMPUTED | SUPPRESS_EFFECT_GC_WARNING);
417
+ ```
418
+
419
+ **Best Practice**: Always properly dispose effects by either:
420
+
421
+ - Calling the returned dispose function
422
+ - Creating effects within a `scope()` that gets disposed
423
+ - Using `setActiveScope()` to track effects to a parent scope
424
+
425
+ **Note**: This warning uses `FinalizationRegistry` internally and only runs in development mode. The entire mechanism is eliminated in production builds.
426
+
427
+ ##### `WARN_ON_UNTRACKED_EFFECT`
428
+
429
+ When enabled, warns when effects are created without an active scope. This is an allowed pattern, but teams may choose to enforce scope usage for better effect lifecycle management.
430
+
431
+ ```js
432
+ import { debugConfig, WARN_ON_UNTRACKED_EFFECT } from "@slimlib/store";
433
+
434
+ debugConfig(WARN_ON_UNTRACKED_EFFECT);
435
+
436
+ // ⚠️ This will now trigger a warning:
437
+ const dispose = effect(() => {
438
+ console.log("No active scope!");
439
+ });
440
+
441
+ // No warning when using a scope:
442
+ const ctx = scope(() => {
443
+ effect(() => {
444
+ console.log("Tracked by scope");
445
+ });
446
+ });
447
+ ```
448
+
449
+ This warning is disabled by default because creating effects without a scope is a valid pattern - developers simply need to manage the dispose function manually. However, teams that prefer all effects to be tracked by scopes can enable this warning to enforce that convention.
450
+
451
+ **Note**: This warning only runs in development mode and is completely eliminated in production builds.
452
+
453
+ ## Features
454
+
455
+ ### Automatic Batching
456
+
457
+ Multiple synchronous updates are automatically batched:
458
+
459
+ ```js
460
+ const store = state({ a: 0, b: 0 });
461
+
462
+ let runs = 0;
463
+ effect(() => {
464
+ store.a;
465
+ store.b;
466
+ runs++;
467
+ });
468
+
469
+ flushEffects(); // runs = 1 (initial)
470
+
471
+ store.a = 1;
472
+ store.b = 2;
473
+ store.a = 3;
474
+
475
+ flushEffects(); // runs = 2 (single batched update)
476
+ ```
477
+
478
+ ### Fine-Grained Tracking
479
+
480
+ Effects only re-run when their specific dependencies change:
481
+
482
+ ```js
483
+ const store = state({ name: "John", age: 30 });
484
+
485
+ effect(() => console.log("Name:", store.name));
486
+ effect(() => console.log("Age:", store.age));
487
+
488
+ store.name = "Jane"; // Only first effect runs
489
+ store.age = 31; // Only second effect runs
490
+ ```
491
+
492
+ ### Conditional Dependencies
493
+
494
+ Dependencies are tracked dynamically based on execution path:
495
+
496
+ ```js
497
+ const store = state({ flag: true, a: 1, b: 2 });
498
+
499
+ effect(() => {
500
+ console.log(store.flag ? store.a : store.b);
501
+ });
502
+
503
+ store.b = 10; // Effect does NOT run (b not tracked when flag is true)
504
+ store.flag = false; // Effect runs, now tracks b instead of a
505
+ store.b = 20; // Effect runs
506
+ store.a = 5; // Effect does NOT run (a not tracked when flag is false)
507
+ ```
508
+
509
+ ### Error Handling in Computeds
510
+
511
+ This library follows the [TC39 Signals proposal](https://github.com/tc39/proposal-signals) for error handling:
512
+
513
+ > Like Promises, Signals can represent an error state: If a computed Signal's callback throws, then that error is cached just like another value, and rethrown every time the Signal is read.
514
+
515
+ When a computed throws an error during evaluation, the error is **cached** and the computed is marked as clean. Subsequent reads will rethrow the cached error without re-executing the callback, until a dependency changes:
516
+
517
+ ```js
518
+ const store = state({ value: -1 });
519
+ let callCount = 0;
520
+
521
+ const safeSqrt = computed(() => {
522
+ callCount++;
523
+ if (store.value < 0) {
524
+ throw new Error("Cannot compute square root of negative number");
525
+ }
526
+ return Math.sqrt(store.value);
527
+ });
528
+
529
+ // First read throws
530
+ try {
531
+ safeSqrt();
532
+ } catch (e) {
533
+ console.log(e.message); // "Cannot compute square root of negative number"
157
534
  }
535
+ console.log(callCount); // 1
536
+
537
+ // Second read rethrows the CACHED error (callback is NOT called again)
538
+ try {
539
+ safeSqrt();
540
+ } catch (e) {
541
+ console.log(e.message); // "Cannot compute square root of negative number"
542
+ }
543
+ console.log(callCount); // Still 1 - callback was not re-executed
544
+
545
+ // Fix the data - this marks the computed as needing re-evaluation
546
+ store.value = 4;
547
+
548
+ // Computed recovers automatically
549
+ console.log(safeSqrt()); // 2
550
+ console.log(callCount); // 3 - callback was called again
158
551
  ```
159
552
 
160
- Publish/subscribe/read pattern implementation. Meant to be used in components / services that want to subscribe for store changes.
553
+ Key behaviors (per TC39 Signals proposal):
161
554
 
162
- ### `react` and `preact` exports
555
+ - Errors **are cached** - the computed will NOT retry on subsequent reads
556
+ - The cached error is rethrown on every read until a dependency changes
557
+ - When a dependency changes, the computed is marked for re-evaluation
558
+ - The computed remains connected to its dependencies even after an error
559
+ - Effects that read throwing computeds should handle errors appropriately
163
560
 
164
- #### `createStore<T>(initialState: T): [T, Store<T>, () => void]`
561
+ ### Cycle Detection
165
562
 
166
- Store factory created with `notifyAfterCreation` === `false`.
563
+ This library follows the [TC39 Signals proposal](https://github.com/tc39/proposal-signals) for cycle detection:
167
564
 
168
- ### `useStore<T>(store: Store<T>): Readonly<T>`
565
+ > It is an error to read a computed recursively.
169
566
 
170
- Function to subscribe to store inside component. Returns current state.
567
+ When a computed signal attempts to read itself (directly or indirectly through other computeds), an error is thrown immediately:
171
568
 
172
- ### `svelte` export
569
+ ```js
570
+ // Direct self-reference
571
+ const self = computed(() => self() + 1);
572
+ self(); // throws: "Detected cycle in computations."
173
573
 
174
- #### `createStore<T>(initialState: T): [T, Store<T>, () => void]`
574
+ // Indirect cycle through multiple computeds
575
+ const a = computed(() => b() + 1);
576
+ const b = computed(() => a() + 1);
577
+ a(); // throws: "Detected cycle in computations."
578
+ ```
175
579
 
176
- Store factory created with `notifyAfterCreation` === `true`.
580
+ Key behaviors:
177
581
 
178
- ### `angular` export
582
+ - Cycles are detected at runtime when the cycle is actually traversed
583
+ - The error is thrown immediately, not cached like regular computed errors
584
+ - Computeds can recover if their dependencies change to break the cycle:
179
585
 
180
- #### `createStore<T>(initialState: T): [T, Store<T>, () => void]`
586
+ ```js
587
+ const store = state({ useCycle: true, value: 10 });
181
588
 
182
- Store factory created with `notifyAfterCreation` === `false`.
589
+ const a = computed(() => {
590
+ if (store.useCycle) {
591
+ return b() + 1; // Creates cycle when useCycle is true
592
+ }
593
+ return store.value;
594
+ });
595
+ const b = computed(() => a() + 1);
183
596
 
184
- #### `toSignal<T>(store: Store<T>): Signal<T>` - converts store to signal
597
+ a(); // throws: "Detected cycle in computations."
185
598
 
186
- #### `SlimlibStore`
599
+ store.useCycle = false; // Break the cycle
600
+ a(); // 10 - works now!
601
+ b(); // 11
602
+ ```
187
603
 
188
- Base class for store services.
604
+ ### Scoped Effects
189
605
 
190
- ##### `constructor(initialState: T)` - creates store with initial state
606
+ When you need an effect that owns inner effects (so they're automatically disposed when the outer effect re-runs), create a scope inside the effect:
191
607
 
192
- ##### `state: T` - store state (proxy object)
193
- ##### `select<R>(...signals: Signal[], projector: (state: T, ...signalValue: SignalValue<signals[index]>) => R): Signal<R>` - selector function that returns a signal
608
+ ```js
609
+ import { effect, scope, state } from "@slimlib/store";
194
610
 
195
- ### `rxjs` export
611
+ const store = state({ items: ["a", "b", "c"] });
196
612
 
197
- #### `createStore<T>(initialState: T): [T, Store<T>, () => void]`
613
+ effect(() => {
614
+ const innerScope = scope();
198
615
 
199
- Store factory created with `notifyAfterCreation` === `false`.
616
+ // Create inner effects for each item
617
+ innerScope(() => {
618
+ store.items.forEach((item) => {
619
+ effect(() => {
620
+ console.log("Item:", item);
621
+ });
622
+ });
623
+ });
200
624
 
201
- #### `toObservable<T>(store: Store<T>): Observable<T>` - converts store to observable
625
+ // Dispose inner scope (and all inner effects) on re-run or dispose
626
+ return () => innerScope();
627
+ });
202
628
 
203
- ## Limitations
629
+ // When items change, outer effect re-runs:
630
+ // 1. Returned cleanup runs, disposing innerScope and all inner effects
631
+ // 2. New inner effects are created for the new items
632
+ store.items = ["x", "y"];
633
+ ```
634
+
635
+ You can wrap this pattern in a helper for reuse:
636
+
637
+ ```js
638
+ import { effect, scope } from "@slimlib/store";
639
+
640
+ /**
641
+ * Creates an effect that acts as a scope for inner effects.
642
+ * Inner effects are automatically disposed when the outer effect re-runs.
643
+ */
644
+ const scopedEffect = (callback) => {
645
+ return effect(() => {
646
+ const innerScope = scope();
647
+ innerScope(callback);
648
+ return () => innerScope();
649
+ });
650
+ };
651
+
652
+ // Usage
653
+ const dispose = scopedEffect(() => {
654
+ effect(() => console.log("Inner effect 1"));
655
+ effect(() => console.log("Inner effect 2"));
656
+ });
657
+
658
+ dispose(); // Disposes outer effect and all inner effects
659
+ ```
660
+
661
+ ### Diamond Problem Solved
662
+
663
+ Effects run only once even when multiple dependencies change:
664
+
665
+ ```js
666
+ const store = state({ value: 1 });
667
+ const a = computed(() => store.value + 1);
668
+ const b = computed(() => store.value + 2);
669
+
670
+ let runs = 0;
671
+ effect(() => {
672
+ a() + b();
673
+ runs++;
674
+ });
675
+
676
+ flushEffects(); // runs = 1
677
+
678
+ store.value = 10;
679
+ flushEffects(); // runs = 2 (not 3!)
680
+ ```
681
+
682
+ ### Automatic Memory Management
683
+
684
+ Computeds use a **liveness tracking** system for automatic memory management. When a computed has no live consumers (effects or other live computeds depending on it), it becomes "non-live" and removes itself from its source dependencies. This allows it to be garbage collected when no external references exist.
685
+
686
+ Key points:
687
+
688
+ - **No manual disposal needed for computeds** - they clean up automatically when unreferenced
689
+ - **Effects still require explicit disposal** via the returned function or scope cleanup
690
+ - **Push/pull hybrid** - live computeds receive push notifications, non-live computeds poll on read
691
+
692
+ ## Migration from v1.x
204
693
 
205
- `Map`, `Set`, `WeakMap`, `WeakSet` cannot be used as values in current implementation.
694
+ v2.0 is a breaking change. Key differences:
206
695
 
207
- Mixing proxied values and values from an underlying object can fail for cases where code needs checking for equality.
696
+ | v1.x | v2.x |
697
+ | ------------------------------------------------ | ---------------------------- |
698
+ | `const [proxy, store, notify] = createStore({})` | `const store = state({})` |
699
+ | `store(callback)` for subscription | `effect(() => { ... })` |
700
+ | `store()` to get raw value | `unwrapValue(store)` |
701
+ | `notify()` for manual notification | Automatic (no manual notify) |
702
+
703
+ ### Before (v1.x)
704
+
705
+ ```js
706
+ const [state, store] = createStore({ count: 0 });
707
+ const unsubscribe = store((value) => console.log(value.count));
708
+ state.count = 1;
709
+ ```
710
+
711
+ ### After (v2.x)
712
+
713
+ ```js
714
+ import { state, effect } from "@slimlib/store";
715
+
716
+ const store = state({ count: 0 });
717
+ const dispose = effect(() => console.log(store.count));
718
+ store.count = 1;
719
+ ```
720
+
721
+ ## Development Warnings
722
+
723
+ The library includes development-time warnings that help catch common mistakes. These warnings:
724
+
725
+ 1. **Are DEV-only** - Only run when `esm-env`'s `DEV` flag is true
726
+ 2. **Are tree-shakeable** - Completely eliminated in production builds
727
+
728
+ | Warning | Default | Flag |
729
+ | ---------------------------- | ----------- | --------------------------------------- |
730
+ | Effect GC'd without disposal | **Enabled** | `SUPPRESS_EFFECT_GC_WARNING` to disable |
731
+ | Writing in computed | Disabled | `WARN_ON_WRITE_IN_COMPUTED` to enable |
732
+ | Effect without active scope | Disabled | `WARN_ON_UNTRACKED_EFFECT` to enable |
733
+
734
+ ### Configuring Warnings
735
+
736
+ ```js
737
+ import {
738
+ debugConfig,
739
+ WARN_ON_WRITE_IN_COMPUTED,
740
+ WARN_ON_UNTRACKED_EFFECT,
741
+ SUPPRESS_EFFECT_GC_WARNING,
742
+ } from "@slimlib/store";
743
+
744
+ // Enable write-in-computed warnings
745
+ debugConfig(WARN_ON_WRITE_IN_COMPUTED);
746
+
747
+ // Warn when effects are created without a scope
748
+ debugConfig(WARN_ON_UNTRACKED_EFFECT);
749
+
750
+ // Suppress GC warnings (e.g., in tests)
751
+ debugConfig(SUPPRESS_EFFECT_GC_WARNING);
752
+
753
+ // Combine flags
754
+ debugConfig(
755
+ WARN_ON_WRITE_IN_COMPUTED |
756
+ WARN_ON_UNTRACKED_EFFECT |
757
+ SUPPRESS_EFFECT_GC_WARNING
758
+ );
759
+
760
+ // Reset to defaults
761
+ debugConfig(0);
762
+ ```
763
+
764
+ ### Bundler Configuration
765
+
766
+ The warnings use `esm-env` for environment detection. Most bundlers handle this automatically:
767
+
768
+ - **Vite**: Works out of the box - uses `development` condition in dev, `production` in build
769
+ - **Rollup/Webpack**: Configure resolve conditions or use `@rollup/plugin-replace`
770
+
771
+ For truly zero-cost production builds (complete code elimination), ensure your bundler sets the appropriate conditions.
772
+
773
+ ## Limitations
208
774
 
209
- For example searching for an array element from the underlying object in a proxied array will fail.
775
+ - Mixing proxied values and values from an underlying object can fail for equality checks
776
+ - Effects run on microtask by default, not synchronously (use `flushEffects()` for immediate execution)
777
+ - Effects are not removed until the next flush if they already scheduled but disposed
210
778
 
211
- ## Similar projects
779
+ ## Similar Projects
212
780
 
213
- [Valtio](https://github.com/pmndrs/valtio) - more sophisticated but similar approach, less limitations
781
+ - [Alien Signals](https://github.com/stackblitz/alien-signals)
782
+ - [Solid.js Signals](https://www.solidjs.com/docs/latest/api#createsignal) - similar reactive primitives
783
+ - [Valtio](https://github.com/pmndrs/valtio) - proxy-based state management
784
+ - [@preact/signals](https://github.com/preactjs/signals) - signals for Preact
214
785
 
215
- # License
786
+ ## License
216
787
 
217
788
  [MIT](https://github.com/kshutkin/slimlib/blob/main/LICENSE)