@zeix/cause-effect 0.17.0 → 0.17.2

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 (50) hide show
  1. package/.ai-context.md +26 -5
  2. package/.cursorrules +8 -3
  3. package/.github/copilot-instructions.md +13 -4
  4. package/CLAUDE.md +191 -262
  5. package/README.md +268 -420
  6. package/archive/collection.ts +23 -25
  7. package/archive/computed.ts +5 -4
  8. package/archive/list.ts +21 -28
  9. package/archive/memo.ts +4 -2
  10. package/archive/state.ts +2 -1
  11. package/archive/store.ts +21 -32
  12. package/archive/task.ts +6 -9
  13. package/index.dev.js +411 -220
  14. package/index.js +1 -1
  15. package/index.ts +25 -8
  16. package/package.json +1 -1
  17. package/src/classes/collection.ts +103 -77
  18. package/src/classes/composite.ts +28 -33
  19. package/src/classes/computed.ts +90 -31
  20. package/src/classes/list.ts +39 -33
  21. package/src/classes/ref.ts +96 -0
  22. package/src/classes/state.ts +41 -8
  23. package/src/classes/store.ts +47 -30
  24. package/src/diff.ts +2 -1
  25. package/src/effect.ts +19 -9
  26. package/src/errors.ts +31 -1
  27. package/src/match.ts +5 -12
  28. package/src/resolve.ts +3 -2
  29. package/src/signal.ts +0 -1
  30. package/src/system.ts +159 -43
  31. package/src/util.ts +0 -10
  32. package/test/collection.test.ts +383 -67
  33. package/test/computed.test.ts +268 -11
  34. package/test/effect.test.ts +2 -2
  35. package/test/list.test.ts +249 -21
  36. package/test/ref.test.ts +381 -0
  37. package/test/state.test.ts +13 -13
  38. package/test/store.test.ts +473 -28
  39. package/types/index.d.ts +6 -5
  40. package/types/src/classes/collection.d.ts +27 -12
  41. package/types/src/classes/composite.d.ts +4 -4
  42. package/types/src/classes/computed.d.ts +17 -0
  43. package/types/src/classes/list.d.ts +6 -6
  44. package/types/src/classes/ref.d.ts +48 -0
  45. package/types/src/classes/state.d.ts +9 -0
  46. package/types/src/classes/store.d.ts +4 -4
  47. package/types/src/effect.d.ts +1 -2
  48. package/types/src/errors.d.ts +9 -1
  49. package/types/src/system.d.ts +40 -24
  50. package/types/src/util.d.ts +1 -3
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Cause & Effect
2
2
 
3
- Version 0.17.0
3
+ Version 0.17.2
4
4
 
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.
5
+ **Cause & Effect** is a tiny (~5kB gzipped), dependency-free reactive state library for JavaScript. It uses fine-grained signals so derived values and side effects update automatically when their dependencies change.
6
6
 
7
7
  ## What is Cause & Effect?
8
8
 
@@ -10,23 +10,33 @@ Version 0.17.0
10
10
 
11
11
  ### Core Concepts
12
12
 
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()`
16
- - **Store signals**: Hold objects of nested reactive properties: `createStore()`
17
- - **List signals**: Create keyed lists with reactive items: `new List()`
18
- - **Collection signals**: Read-only derived array transformations: `new Collection()`
19
- - **Effects**: Run side effects when signals change: `createEffect()`
13
+ - **State**: mutable value (`new State()`)
14
+ - **Memo**: derived & memoized value (`new Memo()`)
15
+ - **Effect**: runs when dependencies change (`createEffect()`)
16
+ - **Task**: async derived value with cancellation (`new Task()`)
17
+ - **Store**: object with reactive nested props (`createStore()`)
18
+ - **List**: mutable array with stable keys & reactive items (`new List()`)
19
+ - **Collection**: read-only derived arrays from Lists (`new DerivedCollection()`)
20
+ - **Ref**: external mutable objects + manual .notify() (`new Ref()`)
20
21
 
21
22
  ## Key Features
22
23
 
23
- - ⚡ **Reactive States**: Automatic updates when dependencies change
24
- - 🧩 **Composable**: Create a complex signal graph with a minimal API
25
- - ⏱️ **Async Ready**: Built-in `Promise` and `AbortController` support
26
- - 🛡️ **Error Handling**: Built-in helper functions for declarative error handling
27
- - 🔧 **Helper Functions**: `resolve()` and `match()` for type-safe value extraction and pattern matching for suspense and error boundaries
28
- - 🚀 **Performance**: Batching and efficient dependency tracking
29
- - 📦 **Tiny**: Less than 3kB gzipped, tree-shakable, zero dependencies
24
+ - ⚡ **Fine-grained reactivity** with automatic dependency tracking
25
+ - 🧩 **Composable signal graph** with a small API
26
+ - ⏱️ **Async ready** (`Task`, `AbortController`, async `DerivedCollection`)
27
+ - 🛡️ **Declarative error handling** (`resolve()` + `match()`)
28
+ - 🚀 **Batching** and efficient dependency tracking
29
+ - 📦 **Tree-shakable**, zero dependencies
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ # with npm
35
+ npm install @zeix/cause-effect
36
+
37
+ # or with bun
38
+ bun add @zeix/cause-effect
39
+ ```
30
40
 
31
41
  ## Quick Start
32
42
 
@@ -37,7 +47,7 @@ import { createEffect, Memo, State } from '@zeix/cause-effect'
37
47
  const user = new State({ name: 'Alice', age: 30 })
38
48
 
39
49
  // 2. Create computed values
40
- const greeting = Memo(() => `Hello ${user.get().name}!`)
50
+ const greeting = new Memo(() => `Hello ${user.get().name}!`)
41
51
 
42
52
  // 3. React to changes
43
53
  createEffect(() => {
@@ -48,521 +58,333 @@ createEffect(() => {
48
58
  user.update(u => ({ ...u, age: 31 })) // Logs: "Hello Alice! You are 31 years old"
49
59
  ```
50
60
 
51
- ## Installation
52
-
53
- ```bash
54
- # with npm
55
- npm install @zeix/cause-effect
56
-
57
- # or with bun
58
- bun add @zeix/cause-effect
59
- ```
60
-
61
61
  ## Usage of Signals
62
62
 
63
- ### State Signals
63
+ ### State
64
64
 
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.
65
+ A `State` is 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.
66
66
 
67
67
  ```js
68
68
  import { createEffect, State } from '@zeix/cause-effect'
69
69
 
70
70
  const count = new State(42)
71
- createEffect(() => {
72
- console.log(count.get()) // logs '42'
73
- })
74
- count.set(24) // logs '24'
71
+
72
+ createEffect(() => console.log(count.get()))
73
+ count.set(24)
74
+
75
75
  document.querySelector('.increment').addEventListener('click', () => {
76
76
  count.update(v => ++v)
77
77
  })
78
- // Click on button logs '25', '26', and so on
79
78
  ```
80
79
 
81
- ### Store Signals
80
+ Use `State` for primitives or for objects you typically replace entirely.
82
81
 
83
- `createStore()` creates a mutable signal that holds an object with nested reactive properties. Each property automatically becomes its own signal with `.get()`, `.set()`, and `.update()` methods. Nested objects recursively become nested stores.
82
+ ### Memo
84
83
 
85
- ```js
86
- import { createStore, createEffect } from '@zeix/cause-effect'
87
-
88
- const user = createStore({
89
- name: 'Alice',
90
- age: 30,
91
- preferences: {
92
- theme: 'dark',
93
- notifications: true
94
- }
95
- })
96
-
97
- // Individual properties are reactive
98
- createEffect(() => {
99
- console.log(`${user.name.get()} is ${user.age.get()} years old`)
100
- })
84
+ A `Memo` is a memoized read-only signal that automatically tracks dependencies and updates only when those dependencies change.
101
85
 
102
- // Nested properties work the same way
103
- createEffect(() => {
104
- console.log(`Theme: ${user.preferences.theme.get()}`)
105
- })
86
+ ```js
87
+ import { State, Memo, createEffect } from '@zeix/cause-effect'
106
88
 
107
- // Update individual properties
108
- user.age.update(v => v + 1) // Logs: "Alice is 31 years old"
109
- user.preferences.theme.set('light') // Logs: "Theme: light"
89
+ const count = new State(42)
90
+ const isEven = new Memo(() => !(count.get() % 2))
110
91
 
111
- // Watch the entire store
112
- createEffect(() => {
113
- console.log('User data:', user.get()) // Triggers on any nested change
114
- })
92
+ createEffect(() => console.log(isEven.get()))
93
+ count.set(24) // no log; still even
115
94
  ```
116
95
 
117
- #### When to Use
118
-
119
- **When to use stores vs states:**
120
-
121
- - **Use `createStore()`** for objects with properties that you want to access and modify individually.
122
- - **Use `new State()`** for primitive values (numbers, strings, booleans) or objects you access and replace entirely.
123
-
124
- #### Dynamic Properties
125
-
126
- Stores support dynamic property addition and removal at runtime using the `add()` and `remove()` methods:
96
+ **Tip**: For simple derivations, a plain function can be faster:
127
97
 
128
98
  ```js
129
- import { createStore, createEffect } from '@zeix/cause-effect'
130
-
131
- const settings = store({ autoSave: true })
132
-
133
- // Add new properties at runtime
134
- settings.add('timeout', 5000)
135
- console.log(settings.timeout.get()) // 5000
99
+ const isEven = () => !(count.get() % 2)
100
+ ```
136
101
 
137
- // Adding an existing property has no effect
138
- settings.add('autoSave', false) // Ignored - autoSave remains true
102
+ **Advanced**: Reducer-style memos:
139
103
 
140
- // Remove properties
141
- settings.remove('timeout')
142
- console.log(settings.timeout) // undefined
104
+ ```js
105
+ import { State, Memo } from '@zeix/cause-effect'
143
106
 
144
- // Removing non-existent properties has no effect
145
- settings.remove('nonExistent') // Safe - no error thrown
107
+ const actions = new State('reset')
108
+ const counter = new Memo((prev) => {
109
+ switch (actions.get()) {
110
+ case 'increment': return prev + 1
111
+ case 'decrement': return prev - 1
112
+ case 'reset': return 0
113
+ default: return prev
114
+ }
115
+ }, 0)
146
116
  ```
147
117
 
148
- The `add()` and `remove()` methods are optimized for performance:
149
- - They bypass the full reconciliation process used by `set()` and `update()`
150
- - They're perfect for frequent single-property additions/removals
151
- - They trigger the same events and reactivity as other store operations
118
+ ### Task
152
119
 
153
- ### List Signals
154
-
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:
120
+ A `Task` handles asynchronous computations with cancellation support:
156
121
 
157
122
  ```js
158
- import { List, createEffect } from '@zeix/cause-effect'
159
-
160
- const items = new List(['banana', 'apple', 'cherry'])
123
+ import { State, Task } from '@zeix/cause-effect'
161
124
 
162
- // Duck-typing: behaves like an array
163
- console.log(items.length) // 3
164
- console.log(typeof items.length) // 'number'
125
+ const id = new State(1)
165
126
 
166
- // Individual items are reactive
167
- createEffect(() => {
168
- console.log(`First item: ${items[0].get()}`)
127
+ const data = new Task(async (oldValue, abort) => {
128
+ const response = await fetch(`/api/users/${id.get()}`, { signal: abort })
129
+ if (!response.ok) throw new Error('Failed to fetch')
130
+ return response.json()
169
131
  })
170
132
 
171
- // Single-parameter add() appends to end
172
- items.add('date') // Adds at index 3
173
- console.log(items.get()) // ['banana', 'apple', 'cherry', 'date']
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
-
179
- // Efficient sorting preserves signal references
180
- items.sort() // Default: string comparison
181
- console.log(items.get()) // ['apple', 'banana', 'cherry', 'date', 'orange']
182
-
183
- // Custom sorting
184
- items.sort((a, b) => b.localeCompare(a)) // Reverse alphabetical
185
- console.log(items.get()) // ['orange', 'date', 'cherry', 'banana', 'apple']
133
+ id.set(2) // cancels previous fetch automatically
186
134
  ```
187
135
 
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'
136
+ **Note**: Use Task (not plain async functions) when you want memoization + cancellation + reactive pending/error states.
202
137
 
203
- const users = new List(
204
- [{ id: 'bob', name: 'Bob' }, { id: 'alice', name: 'Alice' }],
205
- user => user.id
206
- )
138
+ ### Store
207
139
 
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'
217
- ```
218
-
219
- ### Collection Signals
220
-
221
- `new Collection()` creates read-only derived arrays that transform items from Lists with automatic memoization and async support:
140
+ A `Store` is a reactive object. Each property automatically becomes its own signal with `.get()`, `.set()`, and `.update()` methods. Nested objects recursively become nested stores.
222
141
 
223
142
  ```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
- ])
143
+ import { createStore, createEffect } from '@zeix/cause-effect'
231
144
 
232
- // Derived collection - transforms each user
233
- const userProfiles = new Collection(users, user => ({
234
- ...user,
235
- displayName: `${user.name} (${user.role})`
236
- }))
145
+ const user = createStore({
146
+ name: 'Alice',
147
+ age: 30,
148
+ preferences: { theme: 'dark', notifications: true }
149
+ })
237
150
 
238
- // Collections are reactive and memoized
239
151
  createEffect(() => {
240
- console.log('Profiles:', userProfiles.get())
241
- // [{ id: 1, name: 'Alice', role: 'admin', displayName: 'Alice (admin)' }, ...]
152
+ console.log(`${user.name.get()} is ${user.age.get()} years old`)
242
153
  })
243
154
 
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
- })
155
+ user.age.update(v => v + 1)
156
+ user.preferences.theme.set('light')
252
157
 
253
- // Collections can be chained
254
- const adminProfiles = new Collection(userProfiles, profile =>
255
- profile.role === 'admin' ? profile : null
256
- ).filter(Boolean) // Remove null values
158
+ // Watch the full object
159
+ createEffect(() => console.log('User:', user.get()))
257
160
  ```
258
161
 
259
- Collections support access by index or key:
162
+ Dynamic properties using the `add()` and `remove()` methods:
260
163
 
261
164
  ```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
165
+ const settings = createStore({ autoSave: true })
284
166
 
285
- #### Store Change Notifications
167
+ settings.add('timeout', 5000)
168
+ settings.remove('timeout')
169
+ ```
286
170
 
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:
171
+ Subscribe to hooks using the `.on()` method:
288
172
 
289
173
  ```js
290
- import { createStore } from '@zeix/cause-effect'
291
-
292
174
  const user = createStore({ name: 'Alice', age: 30 })
293
175
 
294
- // Listen for property additions
295
- const offAdd = user.on('add', (added) => {
296
- console.log('Added properties:', added)
297
- })
298
-
299
- // Listen for property changes
300
- const offChange = user.on('change', (changed) => {
301
- console.log('Changed properties:', changed)
302
- })
303
-
304
- // Listen for property removals
305
- const offRemove = user.on('remove', (removed) => {
306
- console.log('Removed properties:', removed)
307
- })
308
-
309
- // These will trigger the respective notifications:
310
- user.add('email', 'alice@example.com') // Logs: "Added properties: { email: 'alice@example.com' }"
311
- user.age.set(31) // Logs: "Changed properties: { age: 31 }"
312
- user.remove('email') // Logs: "Removed properties: { email: UNSET }"
176
+ const offChange = user.on('change', changed => console.log(changed))
177
+ const offAdd = user.on('add', added => console.log(added))
178
+ const offRemove = user.on('remove', removed => console.log(removed))
313
179
 
314
- // Listen for sort notifications (useful for UI animations)
315
- const items = new List(['banana', 'apple', 'cherry'])
316
- items.sort((a, b) => b.localeCompare(a)) // Reverse alphabetical
317
- const offSort = items.on('sort', (newOrder) => {
318
- console.log('Items reordered:', newOrder) // ['2', '1', '0']
319
- })
180
+ // These will trigger the respective hooks:
181
+ user.add('email', 'alice@example.com') // Logs: "Added properties: ['email']"
182
+ user.age.set(31) // Logs: "Changed properties: ['age']"
183
+ user.remove('email') // Logs: "Removed properties: ['email']"
320
184
  ```
321
185
 
322
- Notifications are also fired when using `set()`, `update()`, or `splice()` methods:
186
+ To unregister hooks, call the returned cleanup functions:
323
187
 
324
188
  ```js
325
- // This will fire multiple notifications based on what changed
326
- user.update(u => ({ ...u, name: 'Bob', city: 'New York' }))
327
- // Logs: "Changed properties: { name: 'Bob' }"
328
- // Logs: "Added properties: { city: 'New York' }"
189
+ offAdd() // Stop listening to add hook
190
+ offChange() // Stop listening to change hook
191
+ offRemove() // Stop listening to remove hook
329
192
  ```
330
193
 
331
- To stop listening to notifications, call the returned cleanup function:
194
+ ### List
332
195
 
333
- ```js
334
- offAdd() // Stops listening to add notifications
335
- offChange() // Stops listening to change notifications
336
- offRemove() // Stops listening to remove notifications
337
- offSort() // Stops listening to sort notifications
338
- ```
196
+ A `List` is 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:
339
197
 
340
- ### Computed Signals
198
+ ```js
199
+ import { List, createEffect } from '@zeix/cause-effect'
341
200
 
342
- `new Memo()` creates a memoized read-only signal that automatically tracks dependencies and updates only when those dependencies change.
201
+ const items = new List(['banana', 'apple', 'cherry'])
343
202
 
344
- ```js
345
- import { createEffect, Memo, State } from '@zeix/cause-effect'
203
+ createEffect(() => console.log(`First: ${items[0].get()}`))
346
204
 
347
- const count = new State(42)
348
- const isEven = new Memo(() => !(count.get() % 2))
349
- createEffect(() => console.log(isEven.get())) // logs 'true'
350
- count.set(24) // logs nothing because 24 is also an even number
351
- document.querySelector('button.increment').addEventListener('click', () => {
352
- count.update(v => ++v)
353
- })
354
- // Click on button logs 'false', 'true', and so on
205
+ items.add('date')
206
+ items.splice(1, 1, 'orange')
207
+ items.sort()
355
208
  ```
356
209
 
357
- #### When to Use
358
-
359
- **Performance tip**: For simple derivations, plain functions often outperform computed signals:
210
+ Keys are stable across reordering:
360
211
 
361
212
  ```js
362
- // More performant for simple calculations
363
- const isEven = () => !(count.get() % 2)
213
+ const items = new List(['banana', 'apple'], 'item-')
214
+ const key = items.add('orange')
215
+
216
+ items.sort()
217
+ console.log(items.byKey(key)) // 'orange'
218
+ console.log(items.indexOfKey(key)) // current index
364
219
  ```
365
220
 
366
- **When to use which approach:**
221
+ Lists have `.add()`, `.remove()` and `.on()` methods like stores. In addition, they have `.sort()` and `.splice()` methods. But unlike stores, deeply nested properties in items are not converted to individual signals.
367
222
 
368
- - **Use functions when**: The calculation is simple, inexpensive, or called infrequently.
369
- - **Use new Memo() when**:
370
- - The calculation is expensive
371
- - You need to share the result between multiple consumers
372
- - You're working with asynchronous operations
373
- - You need to track specific error states
374
-
375
- #### Reducer Capabilities
223
+ ### Collection
376
224
 
377
- `new Memo()` supports reducer patterns by accepting an initial value and providing access to the previous value in the callback:
225
+ A `Collection` is a read-only derived reactive list from `List` or another `Collection`:
378
226
 
379
227
  ```js
380
- import { createEffect, Memo, State } from '@zeix/cause-effect'
381
-
382
- const actions = new State('increment')
383
- const counter = new Memo((prev) => {
384
- const action = actions.get()
385
- switch (action) {
386
- case 'increment':
387
- return prev + 1
388
- case 'decrement':
389
- return prev - 1
390
- case 'reset':
391
- return 0
392
- default:
393
- return prev
394
- }
395
- }, 0) // Initial value of 0
228
+ import { List, createEffect } from '@zeix/cause-effect'
396
229
 
397
- createEffect(() => console.log('Counter:', counter.get()))
230
+ const users = new List([
231
+ { id: 1, name: 'Alice', role: 'admin' },
232
+ { id: 2, name: 'Bob', role: 'user' }
233
+ ])
234
+ const profiles = users.deriveCollection(user => ({
235
+ ...user,
236
+ displayName: `${user.name} (${user.role})`
237
+ }))
398
238
 
399
- // Dispatch actions
400
- actions.set('increment') // Counter: 1
401
- actions.set('increment') // Counter: 2
402
- actions.set('decrement') // Counter: 1
403
- actions.set('reset') // Counter: 0
239
+ createEffect(() => console.log('Profiles:', profiles.get()))
240
+ console.log(userProfiles.at(0).get().displayName)
404
241
  ```
405
242
 
406
- This pattern is particularly useful for:
407
- - State machines with transitions based on current state
408
- - Accumulating values over time
409
- - Complex state updates that depend on previous state
410
- - Building reactive reducers similar to Redux patterns
243
+ Async mapping is supported:
411
244
 
412
- #### Asynchronous Computations with Automatic Cancellation
245
+ ```js
246
+ const details = users.derivedCollection(async (user, abort) => {
247
+ const response = await fetch(`/users/${user.id}`, { signal: abort })
248
+ return { ...user, details: await response.json() }
249
+ })
250
+ ```
413
251
 
414
- `new Task()` seamlessly handles asynchronous operations with built-in cancellation support. When used with an async function, it:
252
+ ### Ref
415
253
 
416
- 1. Provides an `abort` signal parameter you can pass to fetch or other cancelable APIs
417
- 2. Automatically cancels pending operations when dependencies change
418
- 3. Returns `UNSET` while the Promise is pending
419
- 4. Properly handles errors from failed requests
254
+ A `Ref` is a signal that holds a reference to an external object that can change outside the reactive system.
420
255
 
421
256
  ```js
422
- import { createEffect, match, resolve, State, Task } from '@zeix/cause-effect'
257
+ import { createEffect, Ref } from '@zeix/cause-effect'
423
258
 
424
- const id = new State(42)
425
- const data = new Task(async (_, abort) => {
426
- // The abort signal is automatically managed by the computed signal
427
- const response = await fetch(`/api/entries/${id.get()}`, { signal: abort })
428
- if (!response.ok) throw new Error(`Failed to fetch data: ${response.statusText}`)
429
- return response.json()
430
- })
259
+ const elementRef = new Ref(document.getElementById('status'))
431
260
 
432
- // Handle all possible states using resolve and match helpers
433
- createEffect(() => {
434
- match(resolve({ data }), {
435
- ok: ({ data: json }) => console.log('Data loaded:', json),
436
- nil: () => console.log('Loading...'),
437
- err: errors => console.error('Error:', errors[0])
438
- })
439
- })
261
+ createEffect(() => console.log(elementRef.get().className))
440
262
 
441
- // When id changes, the previous request is automatically canceled
442
- document.querySelector('button.next').addEventListener('click', () => {
443
- id.update(v => ++v)
444
- })
263
+ // external mutation happened
264
+ elementRef.notify()
445
265
  ```
446
266
 
447
- **Note**: Always use `new Task()` (not plain functions) for async operations to benefit from automatic cancellation, memoization, and error handling.
448
-
449
- ## Effects and Error Handling
267
+ Use `Ref` for DOM nodes, Maps/Sets, sockets, third-party objects, etc.
450
268
 
451
- The `createEffect()` function supports both synchronous and asynchronous callbacks:
269
+ ## Effects
452
270
 
453
- ### Synchronous Effects
271
+ The `createEffect()` callback runs whenever the signals it reads change. It supports sync or async callbacks and returns a cleanup function.
454
272
 
455
273
  ```js
456
- import { createEffect, State } from '@zeix/cause-effect'
274
+ import { State, createEffect } from '@zeix/cause-effect'
457
275
 
458
276
  const count = new State(42)
459
- createEffect(() => {
460
- console.log('Count changed:', count.get())
277
+
278
+ const cleanup = createEffect(() => {
279
+ console.log(count.get())
280
+ return () => console.log('Cleanup')
461
281
  })
462
- ```
463
282
 
464
- ### Asynchronous Effects with AbortSignal
283
+ cleanup()
284
+ ```
465
285
 
466
- Async effect callbacks receive an `AbortSignal` parameter that automatically cancels when the effect re-runs or is cleaned up:
286
+ Async effects receive an AbortSignal that cancels on rerun or cleanup:
467
287
 
468
288
  ```js
469
- import { createEffect, State } from '@zeix/cause-effect'
470
-
471
- const userId = new State(1)
472
- createEffect(async (abort) => {
473
- try {
474
- const response = await fetch(`/api/users/${userId.get()}`, { signal: abort })
475
- const user = await response.json()
476
- console.log('User loaded:', user)
477
- } catch (error) {
478
- if (!abort.aborted) {
479
- console.error('Failed to load user:', error)
480
- }
481
- }
289
+ createEffect(async abort => {
290
+ const res = await fetch('/api', { signal: abort })
291
+ if (res.ok) console.log(await res.json())
482
292
  })
483
293
  ```
484
294
 
485
- ### Error Handling with Helper Functions
295
+ ### Error Handling: resolve() + match()
486
296
 
487
- For more sophisticated error handling, use the `resolve()` and `match()` helper functions:
297
+ Use `resolve()` to extract values from signals (including pending/err states) and `match()` to handle them declaratively:
488
298
 
489
299
  ```js
490
- import { createEffect, resolve, match, State } from '@zeix/cause-effect'
300
+ import { State, Task, createEffect, resolve, match } from '@zeix/cause-effect'
491
301
 
492
302
  const userId = new State(1)
493
- const userData = createEffect(async (abort) => {
494
- const response = await fetch(`/api/users/${userId.get()}`, { signal: abort })
495
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
496
- return response.json()
303
+ const userData = new Task(async (_, abort) => {
304
+ const res = await fetch(`/api/users/${userId.get()}`, { signal: abort })
305
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
306
+ return res.json()
497
307
  })
498
308
 
499
309
  createEffect(() => {
500
310
  match(resolve({ userData }), {
501
- ok: ({ userData: user }) => console.log('User loaded:', user),
502
- nil: () => console.log('Loading user...'),
503
- err: errors => console.error('Error loading user:', errors[0])
311
+ ok: ({ userData: user }) => console.log('User:', user),
312
+ nil: () => console.log('Loading...'),
313
+ err: errors => console.error(errors[0])
504
314
  })
505
315
  })
506
316
  ```
507
317
 
508
- The `resolve()` function extracts values from signals and returns a discriminated union result, while `match()` provides pattern matching for handling different states declaratively.
318
+ ## Signal Type Decision Tree
319
+
320
+ ```
321
+ Is the value managed *inside* the reactive system?
322
+
323
+ ├─ No → Use `Ref`
324
+ │ (DOM nodes, Map/Set, Date, sockets, 3rd-party objects)
325
+ │ Remember: call `.notify()` when it changes externally.
326
+
327
+ └─ Yes? What kind of data is it?
328
+
329
+ ├─ *Primitive* (number/string/boolean)
330
+ │ │
331
+ │ ├─ Do you want to mutate it directly?
332
+ │ │ └─ Yes → `State`
333
+ │ │
334
+ │ └─ Is it derived from other signals?
335
+ │ │
336
+ │ ├─ Sync derived
337
+ │ │ ├─ Simple/cheap → plain function (preferred)
338
+ │ │ └─ Expensive/shared/stateful → `Memo`
339
+ │ │
340
+ │ └─ Async derived → `Task`
341
+ │ (cancellation + memoization + pending/error state)
342
+
343
+ ├─ *Plain Object*
344
+ │ │
345
+ │ ├─ Do you want to mutate individual properties?
346
+ │ │ ├─ Yes → `Store`
347
+ │ │ └─ No, whole object mutations only → `State`
348
+ │ │
349
+ │ └─ Is it derived from other signals?
350
+ │ ├─ Sync derived → plain function or `Memo`
351
+ │ └─ Async derived → `Task`
352
+
353
+ └─ *Array*
354
+
355
+ ├─ Do you need to mutate it (add/remove/sort) with stable item identity?
356
+ │ ├─ Yes → `List`
357
+ │ └─ No, whole array mutations only → `State`
358
+
359
+ └─ Is it derived / read-only transformation of a `List` or `Collection`?
360
+ └─ Yes → `Collection`
361
+ (memoized + supports async mapping + chaining)
362
+ ```
509
363
 
510
364
  ## Advanced Usage
511
365
 
512
- ### Batching Updates
366
+ ### Batching
513
367
 
514
- Use `batchSignalWrites()` to group multiple signal updates, ensuring effects run only once after all changes are applied:
368
+ Group multiple signal updates, ensuring effects run only once after all changes are applied:
515
369
 
516
370
  ```js
517
- import {
518
- createEffect,
519
- batchSignalWrites,
520
- resolve,
521
- match,
522
- Memo
523
- State
524
- } from '@zeix/cause-effect'
525
-
526
- // State: define an Array<State<number>>
527
- const signals = [new State(2), new State(3), new State(5)]
528
-
529
- // Compute the sum of all signals
530
- const sum = new Memo(() => {
531
- const v = signals.reduce((total, signal) => total + signal.get(), 0)
532
- // Validate the result
533
- if (!Number.isFinite(v)) throw new Error('Invalid value')
534
- return v
535
- })
371
+ import { batchSignalWrites, State } from '@zeix/cause-effect'
536
372
 
537
- // Effect: handle the result with error handling
538
- createEffect(() => {
539
- match(resolve({ sum }), {
540
- ok: ({ sum: v }) => console.log('Sum:', v),
541
- err: errors => console.error('Error:', errors[0])
542
- })
543
- })
373
+ const a = new State(2)
374
+ const b = new State(3)
544
375
 
545
- // Batch: apply changes to all signals in a single transaction
546
- document.querySelector('.double-all').addEventListener('click', () => {
547
- batch(() => {
548
- signals.forEach(signal => {
549
- signal.update(v => v * 2)
550
- })
551
- })
376
+ batchSignalWrites(() => {
377
+ a.set(4)
378
+ b.set(5)
552
379
  })
553
- // Click on button logs '20' only once
554
- // (instead of first '12', then '15' and then '20' without batch)
555
-
556
- // Provoke an error - but no worries: it will be handled fine
557
- signals[0].set(NaN)
558
380
  ```
559
381
 
560
- ### Cleanup Functions
382
+ ### Cleanup
561
383
 
562
384
  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.
563
385
 
564
386
  ```js
565
- import { createEffect, State } from '@zeix/cause-effect'
387
+ import { State, createEffect } from '@zeix/cause-effect'
566
388
 
567
389
  const user = new State({ name: 'Alice', age: 30 })
568
390
  const greeting = () => `Hello ${user.get().name}!`
@@ -577,31 +399,61 @@ cleanup() // Logs: 'Cleanup' and unsubscribes from signal `user`
577
399
  user.set({ name: 'Bob', age: 28 }) // Won't trigger the effect anymore
578
400
  ```
579
401
 
580
- ## Helper Functions
402
+ ### Resource Management with Hooks
403
+
404
+ All signals support the `watch` hook for lazy resource management. Resources are only allocated when the signal is first accessed by an effect, and automatically cleaned up when no effects are watching:
581
405
 
582
- ### `resolve()` - Extract Signal Values
406
+ ```js
407
+ import { State, createEffect } from '@zeix/cause-effect'
583
408
 
584
- The `resolve()` function extracts values from multiple signals and returns a discriminated union result:
409
+ const config = new State({ apiUrl: 'https://api.example.com' })
410
+
411
+ // Set up lazy resource management
412
+ config.on('watch', () => {
413
+ console.log('Setting up API client...')
414
+ const client = new ApiClient(config.get().apiUrl)
415
+
416
+ // Return cleanup function
417
+ return () => {
418
+ console.log('Cleaning up API client...')
419
+ client.disconnect()
420
+ }
421
+ })
422
+
423
+ // Resource is created only when effect runs
424
+ const cleanup = createEffect(() => {
425
+ console.log('API URL:', config.get().apiUrl)
426
+ })
427
+
428
+ // Resource is cleaned up when effect stops
429
+ cleanup()
430
+ ```
431
+
432
+ This pattern is ideal for:
433
+ - Event listeners that should only be active when data is being watched
434
+ - Network connections that can be lazily established
435
+ - Expensive computations that should pause when not needed
436
+ - External subscriptions (WebSocket, Server-Sent Events, etc.)
437
+
438
+ ### resolve()
439
+
440
+ Extract signal values:
585
441
 
586
442
  ```js
587
- import { Memo, resolve, State } from '@zeix/cause-effect'
443
+ import { State, Memo, resolve } from '@zeix/cause-effect'
588
444
 
589
445
  const name = new State('Alice')
590
446
  const age = new Memo(() => 30)
591
447
  const result = resolve({ name, age })
592
448
 
593
- if (result.ok) {
594
- console.log(result.values.name, result.values.age) // Type-safe access
595
- } else if (result.pending) {
596
- console.log('Loading...')
597
- } else {
598
- console.error('Errors:', result.errors)
599
- }
449
+ if (result.ok) console.log(result.values.name, result.values.age)
450
+ else if (result.pending) console.log('Loading...')
451
+ else console.error('Errors:', result.errors)
600
452
  ```
601
453
 
602
- ### `match()` - Pattern Matching for Side Effects
454
+ ### match()
603
455
 
604
- The `match()` function provides pattern matching on resolve results for side effects:
456
+ Pattern matching on resolved results for side effects:
605
457
 
606
458
  ```js
607
459
  import { resolve, match } from '@zeix/cause-effect'
@@ -613,9 +465,9 @@ match(resolve({ name, age }), {
613
465
  })
614
466
  ```
615
467
 
616
- ### `diff()` - Compare Object Changes
468
+ ### diff()
617
469
 
618
- The `diff()` function compares two objects and returns detailed information about what changed:
470
+ Compare object changes:
619
471
 
620
472
  ```js
621
473
  import { diff } from '@zeix/cause-effect'
@@ -630,11 +482,9 @@ console.log(changes.change) // { age: 31 }
630
482
  console.log(changes.remove) // { city: UNSET }
631
483
  ```
632
484
 
633
- This function is used internally by stores to efficiently determine what changed and emit appropriate events.
485
+ ### isEqual()
634
486
 
635
- ### `isEqual()` - Deep Equality Comparison
636
-
637
- The `isEqual()` function performs deep equality comparison with circular reference detection:
487
+ Deep equality comparison with circular reference detection:
638
488
 
639
489
  ```js
640
490
  import { isEqual } from '@zeix/cause-effect'
@@ -652,12 +502,10 @@ console.log(isEqual('hello', 'hello')) // true
652
502
  console.log(isEqual({ a: [1, 2] }, { a: [1, 2] })) // true
653
503
  ```
654
504
 
655
- Both `diff()` and `isEqual()` include built-in protection against circular references and will throw a `CircularDependencyError` if cycles are detected.
656
-
657
505
  ## Contributing & License
658
506
 
659
507
  Feel free to contribute, report issues, or suggest improvements.
660
508
 
661
509
  License: [MIT](LICENSE)
662
510
 
663
- (c) 2024 2026 [Zeix AG](https://zeix.com)
511
+ (c) 2024 - 2026 [Zeix AG](https://zeix.com)