@slimlib/store 1.6.1 → 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.
- package/README.md +700 -129
- package/dist/index.mjs +1067 -1
- package/dist/index.mjs.map +1 -0
- package/package.json +9 -88
- package/src/computed.ts +232 -0
- package/src/core.ts +434 -0
- package/src/debug.ts +115 -0
- package/src/effect.ts +125 -0
- package/src/flags.ts +38 -0
- package/src/globals.ts +30 -0
- package/src/index.ts +9 -0
- package/src/internal-types.ts +45 -0
- package/src/scope.ts +85 -0
- package/src/signal.ts +55 -0
- package/src/state.ts +170 -0
- package/src/symbols.ts +9 -0
- package/src/types.ts +47 -0
- package/types/index.d.ts +129 -0
- package/types/index.d.ts.map +52 -0
- package/angular/package.json +0 -5
- package/core/package.json +0 -5
- package/dist/angular.cjs +0 -37
- package/dist/angular.d.ts +0 -23
- package/dist/angular.mjs +0 -33
- package/dist/angular.umd.js +0 -2
- package/dist/angular.umd.js.map +0 -1
- package/dist/core.cjs +0 -79
- package/dist/core.d.ts +0 -8
- package/dist/core.mjs +0 -76
- package/dist/index.cjs +0 -8
- package/dist/index.d.ts +0 -1
- package/dist/index.umd.js +0 -2
- package/dist/index.umd.js.map +0 -1
- package/dist/preact.cjs +0 -16
- package/dist/preact.d.ts +0 -3
- package/dist/preact.mjs +0 -13
- package/dist/preact.umd.js +0 -2
- package/dist/preact.umd.js.map +0 -1
- package/dist/react.cjs +0 -16
- package/dist/react.d.ts +0 -3
- package/dist/react.mjs +0 -13
- package/dist/react.umd.js +0 -2
- package/dist/react.umd.js.map +0 -1
- package/dist/rxjs.cjs +0 -18
- package/dist/rxjs.d.ts +0 -3
- package/dist/rxjs.mjs +0 -15
- package/dist/rxjs.umd.js +0 -2
- package/dist/rxjs.umd.js.map +0 -1
- package/dist/svelte.cjs +0 -7
- package/dist/svelte.d.ts +0 -1
- package/dist/svelte.mjs +0 -5
- package/dist/svelte.umd.js +0 -2
- package/dist/svelte.umd.js.map +0 -1
- package/preact/package.json +0 -5
- package/react/package.json +0 -5
- package/rxjs/package.json +0 -5
- package/svelte/package.json +0 -5
package/README.md
CHANGED
|
@@ -1,217 +1,788 @@
|
|
|
1
1
|
# Store
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Reactive state management for SPAs with automatic dependency tracking.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
20
|
+
## Installation
|
|
13
21
|
|
|
14
|
-
|
|
22
|
+
```bash
|
|
23
|
+
npm install @slimlib/store
|
|
15
24
|
```
|
|
16
|
-
|
|
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
|
-
|
|
53
|
+
## API
|
|
20
54
|
|
|
21
|
-
###
|
|
55
|
+
### Reactive Primitives
|
|
22
56
|
|
|
23
|
-
|
|
24
|
-
import { createStore, useStore } from '@slimlib/store/react';
|
|
57
|
+
#### `state<T>(object?: T): T`
|
|
25
58
|
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
state.field = value;
|
|
32
|
-
}
|
|
61
|
+
```js
|
|
62
|
+
const store = state({ user: { name: "John" }, items: [] });
|
|
33
63
|
|
|
34
|
-
//
|
|
35
|
-
|
|
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
|
-
|
|
68
|
+
#### `signal<T>(initialValue?: T): (() => T) & { set: (value: T) => void }`
|
|
43
69
|
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
72
|
+
```js
|
|
73
|
+
import { signal, effect } from "@slimlib/store";
|
|
49
74
|
|
|
50
|
-
|
|
51
|
-
function doSomething() {
|
|
52
|
-
state.field = value;
|
|
53
|
-
}
|
|
75
|
+
const count = signal(0);
|
|
54
76
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
85
|
+
#### `computed<T>(getter: () => T, equals?: (a: T, b: T) => boolean): () => T`
|
|
64
86
|
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
const [
|
|
92
|
+
```js
|
|
93
|
+
const store = state({ items: [1, 2, 3] });
|
|
72
94
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
100
|
+
store.items.push(4);
|
|
101
|
+
console.log(doubled()); // 20
|
|
79
102
|
```
|
|
80
103
|
|
|
81
|
-
|
|
104
|
+
##### Reactive vs Imperative Usage
|
|
105
|
+
|
|
106
|
+
Computeds support two usage patterns:
|
|
82
107
|
|
|
83
|
-
|
|
84
|
-
<script>
|
|
85
|
-
import { storeName } from './stores/storeName';
|
|
86
|
-
</script>
|
|
108
|
+
**Reactive** - tracked by effects, automatically re-evaluated:
|
|
87
109
|
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
119
|
+
**Imperative** - called directly from regular code on-demand:
|
|
93
120
|
|
|
94
|
-
|
|
121
|
+
```js
|
|
122
|
+
const count = signal(0);
|
|
123
|
+
const doubled = computed(() => count() * 2);
|
|
95
124
|
|
|
96
|
-
|
|
97
|
-
|
|
125
|
+
// No effect needed - just read when you want
|
|
126
|
+
console.log(doubled()); // 0
|
|
98
127
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
164
|
+
For managing multiple effects, use a `scope`:
|
|
117
165
|
|
|
118
|
-
```
|
|
119
|
-
import {
|
|
166
|
+
```js
|
|
167
|
+
import { scope, effect, state } from "@slimlib/store";
|
|
120
168
|
|
|
121
|
-
|
|
122
|
-
const [state, store] = createStore();
|
|
169
|
+
const store = state({ count: 0 });
|
|
123
170
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
//
|
|
130
|
-
export const state$ = toObservable(store);
|
|
176
|
+
ctx(); // Dispose all effects at once
|
|
131
177
|
```
|
|
132
178
|
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
+
```js
|
|
265
|
+
import { setActiveScope, scope, effect, state } from "@slimlib/store";
|
|
138
266
|
|
|
139
|
-
|
|
267
|
+
const store = state({ count: 0 });
|
|
268
|
+
const appScope = scope();
|
|
140
269
|
|
|
141
|
-
|
|
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
|
-
|
|
366
|
+
##### `WARN_ON_WRITE_IN_COMPUTED`
|
|
144
367
|
|
|
145
|
-
|
|
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
|
-
|
|
370
|
+
```js
|
|
371
|
+
import { debugConfig, WARN_ON_WRITE_IN_COMPUTED } from "@slimlib/store";
|
|
148
372
|
|
|
149
|
-
|
|
373
|
+
debugConfig(WARN_ON_WRITE_IN_COMPUTED);
|
|
150
374
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
553
|
+
Key behaviors (per TC39 Signals proposal):
|
|
161
554
|
|
|
162
|
-
|
|
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
|
-
|
|
561
|
+
### Cycle Detection
|
|
165
562
|
|
|
166
|
-
|
|
563
|
+
This library follows the [TC39 Signals proposal](https://github.com/tc39/proposal-signals) for cycle detection:
|
|
167
564
|
|
|
168
|
-
|
|
565
|
+
> It is an error to read a computed recursively.
|
|
169
566
|
|
|
170
|
-
|
|
567
|
+
When a computed signal attempts to read itself (directly or indirectly through other computeds), an error is thrown immediately:
|
|
171
568
|
|
|
172
|
-
|
|
569
|
+
```js
|
|
570
|
+
// Direct self-reference
|
|
571
|
+
const self = computed(() => self() + 1);
|
|
572
|
+
self(); // throws: "Detected cycle in computations."
|
|
173
573
|
|
|
174
|
-
|
|
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
|
-
|
|
580
|
+
Key behaviors:
|
|
177
581
|
|
|
178
|
-
|
|
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
|
-
|
|
586
|
+
```js
|
|
587
|
+
const store = state({ useCycle: true, value: 10 });
|
|
181
588
|
|
|
182
|
-
|
|
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
|
-
|
|
597
|
+
a(); // throws: "Detected cycle in computations."
|
|
185
598
|
|
|
186
|
-
|
|
599
|
+
store.useCycle = false; // Break the cycle
|
|
600
|
+
a(); // 10 - works now!
|
|
601
|
+
b(); // 11
|
|
602
|
+
```
|
|
187
603
|
|
|
188
|
-
|
|
604
|
+
### Scoped Effects
|
|
189
605
|
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
608
|
+
```js
|
|
609
|
+
import { effect, scope, state } from "@slimlib/store";
|
|
194
610
|
|
|
195
|
-
|
|
611
|
+
const store = state({ items: ["a", "b", "c"] });
|
|
196
612
|
|
|
197
|
-
|
|
613
|
+
effect(() => {
|
|
614
|
+
const innerScope = scope();
|
|
198
615
|
|
|
199
|
-
|
|
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
|
-
|
|
625
|
+
// Dispose inner scope (and all inner effects) on re-run or dispose
|
|
626
|
+
return () => innerScope();
|
|
627
|
+
});
|
|
202
628
|
|
|
203
|
-
|
|
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
|
-
|
|
694
|
+
v2.0 is a breaking change. Key differences:
|
|
206
695
|
|
|
207
|
-
|
|
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
|
-
|
|
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
|
|
779
|
+
## Similar Projects
|
|
212
780
|
|
|
213
|
-
[
|
|
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
|
-
|
|
786
|
+
## License
|
|
216
787
|
|
|
217
788
|
[MIT](https://github.com/kshutkin/slimlib/blob/main/LICENSE)
|