@zeix/cause-effect 0.16.1 → 0.17.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 (61) hide show
  1. package/.ai-context.md +71 -21
  2. package/.cursorrules +3 -2
  3. package/.github/copilot-instructions.md +59 -13
  4. package/CLAUDE.md +170 -24
  5. package/LICENSE +1 -1
  6. package/README.md +156 -52
  7. package/archive/benchmark.ts +688 -0
  8. package/archive/collection.ts +312 -0
  9. package/{src → archive}/computed.ts +19 -19
  10. package/archive/list.ts +551 -0
  11. package/archive/memo.ts +138 -0
  12. package/{src → archive}/state.ts +13 -11
  13. package/archive/store.ts +368 -0
  14. package/archive/task.ts +194 -0
  15. package/eslint.config.js +1 -0
  16. package/index.dev.js +899 -503
  17. package/index.js +1 -1
  18. package/index.ts +41 -22
  19. package/package.json +1 -1
  20. package/src/classes/collection.ts +272 -0
  21. package/src/classes/composite.ts +176 -0
  22. package/src/classes/computed.ts +333 -0
  23. package/src/classes/list.ts +304 -0
  24. package/src/classes/state.ts +98 -0
  25. package/src/classes/store.ts +210 -0
  26. package/src/diff.ts +26 -53
  27. package/src/effect.ts +9 -9
  28. package/src/errors.ts +50 -25
  29. package/src/signal.ts +58 -41
  30. package/src/system.ts +79 -42
  31. package/src/util.ts +16 -30
  32. package/test/batch.test.ts +15 -17
  33. package/test/benchmark.test.ts +4 -4
  34. package/test/collection.test.ts +796 -0
  35. package/test/computed.test.ts +138 -130
  36. package/test/diff.test.ts +2 -2
  37. package/test/effect.test.ts +36 -35
  38. package/test/list.test.ts +754 -0
  39. package/test/match.test.ts +25 -25
  40. package/test/resolve.test.ts +17 -19
  41. package/test/signal.test.ts +70 -119
  42. package/test/state.test.ts +44 -44
  43. package/test/store.test.ts +253 -929
  44. package/types/index.d.ts +10 -8
  45. package/types/src/classes/collection.d.ts +32 -0
  46. package/types/src/classes/composite.d.ts +15 -0
  47. package/types/src/classes/computed.d.ts +97 -0
  48. package/types/src/classes/list.d.ts +41 -0
  49. package/types/src/classes/state.d.ts +52 -0
  50. package/types/src/classes/store.d.ts +51 -0
  51. package/types/src/diff.d.ts +8 -12
  52. package/types/src/errors.d.ts +12 -11
  53. package/types/src/signal.d.ts +27 -14
  54. package/types/src/system.d.ts +41 -20
  55. package/types/src/util.d.ts +6 -3
  56. package/src/store.ts +0 -474
  57. package/types/src/collection.d.ts +0 -26
  58. package/types/src/computed.d.ts +0 -33
  59. package/types/src/scheduler.d.ts +0 -55
  60. package/types/src/state.d.ts +0 -24
  61. package/types/src/store.d.ts +0 -65
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Cause & Effect
2
2
 
3
- Version 0.16.1
3
+ Version 0.17.0
4
4
 
5
5
  **Cause & Effect** is a lightweight, reactive state management library for JavaScript applications. It uses fine-grained reactivity with signals to create predictable and efficient data flow in your app.
6
6
 
@@ -10,9 +10,12 @@ Version 0.16.1
10
10
 
11
11
  ### Core Concepts
12
12
 
13
- - **State signals**: Hold values that can be directly modified: `createState()`
13
+ - **State signals**: Hold values that can be directly modified: `new State()`
14
+ - **Memo signals**: Derive memoized values from other signals: `new Memo()`
15
+ - **Task signals**: Execute asynchronous functions of other signals: `new Task()`
14
16
  - **Store signals**: Hold objects of nested reactive properties: `createStore()`
15
- - **Computed signals**: Derive memoized values from other signals: `createComputed()`
17
+ - **List signals**: Create keyed lists with reactive items: `new List()`
18
+ - **Collection signals**: Read-only derived array transformations: `new Collection()`
16
19
  - **Effects**: Run side effects when signals change: `createEffect()`
17
20
 
18
21
  ## Key Features
@@ -28,13 +31,13 @@ Version 0.16.1
28
31
  ## Quick Start
29
32
 
30
33
  ```js
31
- import { createState, createComputed, createEffect } from '@zeix/cause-effect'
34
+ import { createEffect, Memo, State } from '@zeix/cause-effect'
32
35
 
33
36
  // 1. Create state
34
- const user = createState({ name: 'Alice', age: 30 })
37
+ const user = new State({ name: 'Alice', age: 30 })
35
38
 
36
39
  // 2. Create computed values
37
- const greeting = createComputed(() => `Hello ${user.get().name}!`)
40
+ const greeting = Memo(() => `Hello ${user.get().name}!`)
38
41
 
39
42
  // 3. React to changes
40
43
  createEffect(() => {
@@ -59,12 +62,12 @@ bun add @zeix/cause-effect
59
62
 
60
63
  ### State Signals
61
64
 
62
- `createState()` creates a mutable signal. Every signal has a `.get()` method to access its current value. State signals also provide `.set()` to directly assign a new value and `.update()` to modify the value with a function.
65
+ `new State()` creates a mutable signal. Every signal has a `.get()` method to access its current value. State signals also provide `.set()` to directly assign a new value and `.update()` to modify the value with a function.
63
66
 
64
67
  ```js
65
- import { createState, createEffect } from '@zeix/cause-effect'
68
+ import { createEffect, State } from '@zeix/cause-effect'
66
69
 
67
- const count = createState(42)
70
+ const count = new State(42)
68
71
  createEffect(() => {
69
72
  console.log(count.get()) // logs '42'
70
73
  })
@@ -116,7 +119,7 @@ createEffect(() => {
116
119
  **When to use stores vs states:**
117
120
 
118
121
  - **Use `createStore()`** for objects with properties that you want to access and modify individually.
119
- - **Use `createState()`** for primitive values (numbers, strings, booleans) or objects you access and replace entirely.
122
+ - **Use `new State()`** for primitive values (numbers, strings, booleans) or objects you access and replace entirely.
120
123
 
121
124
  #### Dynamic Properties
122
125
 
@@ -147,14 +150,14 @@ The `add()` and `remove()` methods are optimized for performance:
147
150
  - They're perfect for frequent single-property additions/removals
148
151
  - They trigger the same events and reactivity as other store operations
149
152
 
150
- #### Array-like Stores
153
+ ### List Signals
151
154
 
152
- Stores created from arrays behave like arrays with reactive properties. They support duck-typing with length property, single-parameter `add()`, and efficient sorting:
155
+ `new List()` creates a mutable signal for arrays with individually reactive items and stable keys. Each item becomes its own signal while maintaining persistent identity through sorting and reordering:
153
156
 
154
157
  ```js
155
- import { createStore, createEffect } from '@zeix/cause-effect'
158
+ import { List, createEffect } from '@zeix/cause-effect'
156
159
 
157
- const items = createStore(['banana', 'apple', 'cherry'])
160
+ const items = new List(['banana', 'apple', 'cherry'])
158
161
 
159
162
  // Duck-typing: behaves like an array
160
163
  console.log(items.length) // 3
@@ -169,15 +172,116 @@ createEffect(() => {
169
172
  items.add('date') // Adds at index 3
170
173
  console.log(items.get()) // ['banana', 'apple', 'cherry', 'date']
171
174
 
175
+ // Splice allows removal and insertion at specific indices
176
+ items.splice(1, 1, 'orange') // Removes 'apple' and inserts 'orange' at index 1
177
+ console.log(items.get()) // ['banana', 'orange', 'cherry', 'date']
178
+
172
179
  // Efficient sorting preserves signal references
173
180
  items.sort() // Default: string comparison
174
- console.log(items.get()) // ['apple', 'banana', 'cherry', 'date']
181
+ console.log(items.get()) // ['apple', 'banana', 'cherry', 'date', 'orange']
175
182
 
176
183
  // Custom sorting
177
184
  items.sort((a, b) => b.localeCompare(a)) // Reverse alphabetical
178
- console.log(items.get()) // ['date', 'cherry', 'banana', 'apple']
185
+ console.log(items.get()) // ['orange', 'date', 'cherry', 'banana', 'apple']
186
+ ```
187
+
188
+ List signals have stable unique keys for entries. This means that the keys for each item in the list will not change even if the items are reordered. Keys default to a string representation of an auto-incrementing number. You can customize keys by passing a prefix string or a function to derive the key from the entry value as the second argument to `new List()`:
189
+
190
+ ```js
191
+ const items = new List(['banana', 'apple', 'cherry', 'date'], 'item-')
192
+
193
+ // Add returns the key of the added item
194
+ const orangeKey = items.add('orange')
195
+
196
+ // Sort preserves signal references
197
+ items.sort()
198
+ console.log(items.get()) // ['apple', 'banana', 'cherry', 'date', 'orange']
199
+
200
+ // Access items by key
201
+ console.log(items.byKey(orangeKey)) // 'orange'
202
+
203
+ const users = new List(
204
+ [{ id: 'bob', name: 'Bob' }, { id: 'alice', name: 'Alice' }],
205
+ user => user.id
206
+ )
207
+
208
+ // Sort preserves signal references
209
+ users.sort((a, b) => a.name.localeCompare(b.name)) // Alphabetical by name
210
+ console.log(users.get()) // [{ id: 'alice', name: 'Alice' }, { id: 'bob', name: 'Bob' }]
211
+
212
+ // Get current positional index for an item
213
+ console.log(users.indexOfKey('alice')) // 0
214
+
215
+ // Get key at index
216
+ console.log(users.keyAt(1)) // 'bob'
179
217
  ```
180
218
 
219
+ ### Collection Signals
220
+
221
+ `new Collection()` creates read-only derived arrays that transform items from Lists with automatic memoization and async support:
222
+
223
+ ```js
224
+ import { List, Collection, createEffect } from '@zeix/cause-effect'
225
+
226
+ // Source list
227
+ const users = new List([
228
+ { id: 1, name: 'Alice', role: 'admin' },
229
+ { id: 2, name: 'Bob', role: 'user' }
230
+ ])
231
+
232
+ // Derived collection - transforms each user
233
+ const userProfiles = new Collection(users, user => ({
234
+ ...user,
235
+ displayName: `${user.name} (${user.role})`
236
+ }))
237
+
238
+ // Collections are reactive and memoized
239
+ createEffect(() => {
240
+ console.log('Profiles:', userProfiles.get())
241
+ // [{ id: 1, name: 'Alice', role: 'admin', displayName: 'Alice (admin)' }, ...]
242
+ })
243
+
244
+ // Individual items are computed signals
245
+ console.log(userProfiles.at(0).get().displayName) // 'Alice (admin)'
246
+
247
+ // Collections support async transformations
248
+ const userDetails = new Collection(users, async (user, abort) => {
249
+ const response = await fetch(`/users/${user.id}`, { signal: abort })
250
+ return { ...user, details: await response.json() }
251
+ })
252
+
253
+ // Collections can be chained
254
+ const adminProfiles = new Collection(userProfiles, profile =>
255
+ profile.role === 'admin' ? profile : null
256
+ ).filter(Boolean) // Remove null values
257
+ ```
258
+
259
+ Collections support access by index or key:
260
+
261
+ ```js
262
+ // Access by index or key (read-only)
263
+ const firstProfile = userProfiles.at(0) // Returns computed signal
264
+ const profileByKey = userProfiles.byKey('user1') // Access by stable key
265
+
266
+ // Array methods work
267
+ console.log(userProfiles.length) // Reactive length
268
+ for (const profile of userProfiles) {
269
+ console.log(profile.get()) // Each item is a computed signal
270
+ }
271
+
272
+ // Lists can derive collections directly
273
+ const userSummaries = users.deriveCollection(user => ({
274
+ id: user.id,
275
+ summary: `${user.name} is a ${user.role}`
276
+ }))
277
+ ```
278
+
279
+ #### When to Use Collections vs Lists
280
+
281
+ - **Use `new List()`** for mutable arrays where you add, remove, sort, or modify items
282
+ - **Use `new Collection()`** for read-only transformations, filtering, or async processing of Lists
283
+ - **Chain Collections** to create multi-step data pipelines with automatic memoization
284
+
181
285
  #### Store Change Notifications
182
286
 
183
287
  Stores emit notifications (sort of light-weight events) when properties are added, changed, or removed. You can listen to these notications using the `.on()` method:
@@ -208,14 +312,14 @@ user.age.set(31) // Logs: "Changed properties: { age: 31 }
208
312
  user.remove('email') // Logs: "Removed properties: { email: UNSET }"
209
313
 
210
314
  // Listen for sort notifications (useful for UI animations)
211
- const items = createStore(['banana', 'apple', 'cherry'])
315
+ const items = new List(['banana', 'apple', 'cherry'])
212
316
  items.sort((a, b) => b.localeCompare(a)) // Reverse alphabetical
213
317
  const offSort = items.on('sort', (newOrder) => {
214
318
  console.log('Items reordered:', newOrder) // ['2', '1', '0']
215
319
  })
216
320
  ```
217
321
 
218
- Notifications are also fired when using `set()` or `update()` methods on the entire store:
322
+ Notifications are also fired when using `set()`, `update()`, or `splice()` methods:
219
323
 
220
324
  ```js
221
325
  // This will fire multiple notifications based on what changed
@@ -235,13 +339,13 @@ offSort() // Stops listening to sort notifications
235
339
 
236
340
  ### Computed Signals
237
341
 
238
- `createComputed()` creates a memoized read-only signal that automatically tracks dependencies and updates only when those dependencies change.
342
+ `new Memo()` creates a memoized read-only signal that automatically tracks dependencies and updates only when those dependencies change.
239
343
 
240
344
  ```js
241
- import { createState, createComputed, createEffect } from '@zeix/cause-effect'
345
+ import { createEffect, Memo, State } from '@zeix/cause-effect'
242
346
 
243
- const count = createState(42)
244
- const isEven = createComputed(() => !(count.get() % 2))
347
+ const count = new State(42)
348
+ const isEven = new Memo(() => !(count.get() % 2))
245
349
  createEffect(() => console.log(isEven.get())) // logs 'true'
246
350
  count.set(24) // logs nothing because 24 is also an even number
247
351
  document.querySelector('button.increment').addEventListener('click', () => {
@@ -262,21 +366,21 @@ const isEven = () => !(count.get() % 2)
262
366
  **When to use which approach:**
263
367
 
264
368
  - **Use functions when**: The calculation is simple, inexpensive, or called infrequently.
265
- - **Use createComputed() when**:
369
+ - **Use new Memo() when**:
266
370
  - The calculation is expensive
267
371
  - You need to share the result between multiple consumers
268
372
  - You're working with asynchronous operations
269
373
  - You need to track specific error states
270
374
 
271
- #### Reducer-like Capabilities
375
+ #### Reducer Capabilities
272
376
 
273
- `createComputed()` supports reducer-like patterns by accepting an initial value and providing access to the previous value in the callback:
377
+ `new Memo()` supports reducer patterns by accepting an initial value and providing access to the previous value in the callback:
274
378
 
275
379
  ```js
276
- import { createState, createComputed, createEffect } from '@zeix/cause-effect'
380
+ import { createEffect, Memo, State } from '@zeix/cause-effect'
277
381
 
278
- const actions = createState('increment')
279
- const counter = createComputed((prev, abort) => {
382
+ const actions = new State('increment')
383
+ const counter = new Memo((prev) => {
280
384
  const action = actions.get()
281
385
  switch (action) {
282
386
  case 'increment':
@@ -307,7 +411,7 @@ This pattern is particularly useful for:
307
411
 
308
412
  #### Asynchronous Computations with Automatic Cancellation
309
413
 
310
- `createComputed()` seamlessly handles asynchronous operations with built-in cancellation support. When used with an async function, it:
414
+ `new Task()` seamlessly handles asynchronous operations with built-in cancellation support. When used with an async function, it:
311
415
 
312
416
  1. Provides an `abort` signal parameter you can pass to fetch or other cancelable APIs
313
417
  2. Automatically cancels pending operations when dependencies change
@@ -315,10 +419,10 @@ This pattern is particularly useful for:
315
419
  4. Properly handles errors from failed requests
316
420
 
317
421
  ```js
318
- import { createState, createComputed, createEffect, resolve, match } from '@zeix/cause-effect'
422
+ import { createEffect, match, resolve, State, Task } from '@zeix/cause-effect'
319
423
 
320
- const id = createState(42)
321
- const data = createComputed(async (_, abort) => {
424
+ const id = new State(42)
425
+ const data = new Task(async (_, abort) => {
322
426
  // The abort signal is automatically managed by the computed signal
323
427
  const response = await fetch(`/api/entries/${id.get()}`, { signal: abort })
324
428
  if (!response.ok) throw new Error(`Failed to fetch data: ${response.statusText}`)
@@ -340,7 +444,7 @@ document.querySelector('button.next').addEventListener('click', () => {
340
444
  })
341
445
  ```
342
446
 
343
- **Note**: Always use `createComputed()` (not plain functions) for async operations to benefit from automatic cancellation and memoization.
447
+ **Note**: Always use `new Task()` (not plain functions) for async operations to benefit from automatic cancellation, memoization, and error handling.
344
448
 
345
449
  ## Effects and Error Handling
346
450
 
@@ -349,9 +453,9 @@ The `createEffect()` function supports both synchronous and asynchronous callbac
349
453
  ### Synchronous Effects
350
454
 
351
455
  ```js
352
- import { createState, createEffect } from '@zeix/cause-effect'
456
+ import { createEffect, State } from '@zeix/cause-effect'
353
457
 
354
- const count = createState(42)
458
+ const count = new State(42)
355
459
  createEffect(() => {
356
460
  console.log('Count changed:', count.get())
357
461
  })
@@ -362,9 +466,9 @@ createEffect(() => {
362
466
  Async effect callbacks receive an `AbortSignal` parameter that automatically cancels when the effect re-runs or is cleaned up:
363
467
 
364
468
  ```js
365
- import { createState, createEffect } from '@zeix/cause-effect'
469
+ import { createEffect, State } from '@zeix/cause-effect'
366
470
 
367
- const userId = createState(1)
471
+ const userId = new State(1)
368
472
  createEffect(async (abort) => {
369
473
  try {
370
474
  const response = await fetch(`/api/users/${userId.get()}`, { signal: abort })
@@ -383,9 +487,9 @@ createEffect(async (abort) => {
383
487
  For more sophisticated error handling, use the `resolve()` and `match()` helper functions:
384
488
 
385
489
  ```js
386
- import { createState, createEffect, resolve, match } from '@zeix/cause-effect'
490
+ import { createEffect, resolve, match, State } from '@zeix/cause-effect'
387
491
 
388
- const userId = createState(1)
492
+ const userId = new State(1)
389
493
  const userData = createEffect(async (abort) => {
390
494
  const response = await fetch(`/api/users/${userId.get()}`, { signal: abort })
391
495
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
@@ -407,23 +511,23 @@ The `resolve()` function extracts values from signals and returns a discriminate
407
511
 
408
512
  ### Batching Updates
409
513
 
410
- Use `batch()` to group multiple signal updates, ensuring effects run only once after all changes are applied:
514
+ Use `batchSignalWrites()` to group multiple signal updates, ensuring effects run only once after all changes are applied:
411
515
 
412
516
  ```js
413
517
  import {
414
- createState,
415
- createComputed,
416
518
  createEffect,
417
- batch,
519
+ batchSignalWrites,
418
520
  resolve,
419
- match
521
+ match,
522
+ Memo
523
+ State
420
524
  } from '@zeix/cause-effect'
421
525
 
422
526
  // State: define an Array<State<number>>
423
- const signals = [createState(2), createState(3), createState(5)]
527
+ const signals = [new State(2), new State(3), new State(5)]
424
528
 
425
529
  // Compute the sum of all signals
426
- const sum = createComputed(() => {
530
+ const sum = new Memo(() => {
427
531
  const v = signals.reduce((total, signal) => total + signal.get(), 0)
428
532
  // Validate the result
429
533
  if (!Number.isFinite(v)) throw new Error('Invalid value')
@@ -458,9 +562,9 @@ signals[0].set(NaN)
458
562
  Effects return a cleanup function. When executed, it will unsubscribe from signals and run cleanup functions returned by effect callbacks, for example to remove event listeners.
459
563
 
460
564
  ```js
461
- import { createState, createComputed, createEffect } from '@zeix/cause-effect'
565
+ import { createEffect, State } from '@zeix/cause-effect'
462
566
 
463
- const user = createState({ name: 'Alice', age: 30 })
567
+ const user = new State({ name: 'Alice', age: 30 })
464
568
  const greeting = () => `Hello ${user.get().name}!`
465
569
  const cleanup = createEffect(() => {
466
570
  console.log(`${greeting()} You are ${user.get().age} years old`)
@@ -480,10 +584,10 @@ user.set({ name: 'Bob', age: 28 }) // Won't trigger the effect anymore
480
584
  The `resolve()` function extracts values from multiple signals and returns a discriminated union result:
481
585
 
482
586
  ```js
483
- import { createState, createComputed, resolve } from '@zeix/cause-effect'
587
+ import { Memo, resolve, State } from '@zeix/cause-effect'
484
588
 
485
- const name = createState('Alice')
486
- const age = createComputed(() => 30)
589
+ const name = new State('Alice')
590
+ const age = new Memo(() => 30)
487
591
  const result = resolve({ name, age })
488
592
 
489
593
  if (result.ok) {
@@ -556,4 +660,4 @@ Feel free to contribute, report issues, or suggest improvements.
556
660
 
557
661
  License: [MIT](LICENSE)
558
662
 
559
- (c) 2025 [Zeix AG](https://zeix.com)
663
+ (c) 2024 – 2026 [Zeix AG](https://zeix.com)