@zeix/cause-effect 0.17.0 → 0.17.1

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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # Cause & Effect
2
2
 
3
- Version 0.17.0
3
+ Version 0.17.1
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,332 @@ 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
82
-
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.
80
+ Use `State` for primitives or for objects you typically replace entirely.
84
81
 
85
- ```js
86
- import { createStore, createEffect } from '@zeix/cause-effect'
82
+ ### Memo
87
83
 
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
152
-
153
- ### List Signals
118
+ ### Task
154
119
 
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'
123
+ import { State, Task } from '@zeix/cause-effect'
159
124
 
160
- const items = new List(['banana', 'apple', 'cherry'])
161
-
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'
202
-
203
- const users = new List(
204
- [{ id: 'bob', name: 'Bob' }, { id: 'alice', name: 'Alice' }],
205
- user => user.id
206
- )
136
+ **Note**: Use Task (not plain async functions) when you want memoization + cancellation + reactive pending/error states.
207
137
 
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' }]
138
+ ### Store
211
139
 
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
165
+ const settings = createStore({ autoSave: true })
280
166
 
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
-
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
+ Change Notifications 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
- })
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))
308
179
 
309
180
  // 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 }"
313
-
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
- })
320
- ```
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']"
321
184
 
322
- Notifications are also fired when using `set()`, `update()`, or `splice()` methods:
185
+ To stop listening to notifications, call the returned cleanup functions:
323
186
 
324
187
  ```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' }"
188
+ offAdd() // Stop listening to add notifications
189
+ offChange() // Stop listening to change notifications
190
+ offRemove() // Stop listening to remove notifications
329
191
  ```
330
192
 
331
- To stop listening to notifications, call the returned cleanup function:
193
+ ### List
194
+
195
+ 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:
332
196
 
333
197
  ```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
- ```
198
+ import { List, createEffect } from '@zeix/cause-effect'
339
199
 
340
- ### Computed Signals
200
+ const items = new List(['banana', 'apple', 'cherry'])
341
201
 
342
- `new Memo()` creates a memoized read-only signal that automatically tracks dependencies and updates only when those dependencies change.
202
+ createEffect(() => console.log(`First: ${items[0].get()}`))
343
203
 
344
- ```js
345
- import { createEffect, Memo, State } from '@zeix/cause-effect'
346
-
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
204
+ items.add('date')
205
+ items.splice(1, 1, 'orange')
206
+ items.sort()
355
207
  ```
356
208
 
357
- #### When to Use
358
-
359
- **Performance tip**: For simple derivations, plain functions often outperform computed signals:
209
+ Keys are stable across reordering:
360
210
 
361
211
  ```js
362
- // More performant for simple calculations
363
- const isEven = () => !(count.get() % 2)
212
+ const items = new List(['banana', 'apple'], 'item-')
213
+ const key = items.add('orange')
214
+
215
+ items.sort()
216
+ console.log(items.byKey(key)) // 'orange'
217
+ console.log(items.indexOfKey(key)) // current index
364
218
  ```
365
219
 
366
- **When to use which approach:**
220
+ 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
221
 
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
222
+ ### Collection
376
223
 
377
- `new Memo()` supports reducer patterns by accepting an initial value and providing access to the previous value in the callback:
224
+ A `Collection` is a read-only derived reactive list from `List` or another `Collection`:
378
225
 
379
226
  ```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
227
+ import { List, createEffect } from '@zeix/cause-effect'
396
228
 
397
- createEffect(() => console.log('Counter:', counter.get()))
229
+ const users = new List([
230
+ { id: 1, name: 'Alice', role: 'admin' },
231
+ { id: 2, name: 'Bob', role: 'user' }
232
+ ])
233
+ const profiles = users.deriveCollection(user => ({
234
+ ...user,
235
+ displayName: `${user.name} (${user.role})`
236
+ }))
398
237
 
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
238
+ createEffect(() => console.log('Profiles:', profiles.get()))
239
+ console.log(userProfiles.at(0).get().displayName)
404
240
  ```
405
241
 
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
242
+ Async mapping is supported:
411
243
 
412
- #### Asynchronous Computations with Automatic Cancellation
244
+ ```js
245
+ const details = users.derivedCollection(async (user, abort) => {
246
+ const response = await fetch(`/users/${user.id}`, { signal: abort })
247
+ return { ...user, details: await response.json() }
248
+ })
249
+ ```
413
250
 
414
- `new Task()` seamlessly handles asynchronous operations with built-in cancellation support. When used with an async function, it:
251
+ ### Ref
415
252
 
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
253
+ A `Ref` is a signal that holds a reference to an external object that can change outside the reactive system.
420
254
 
421
255
  ```js
422
- import { createEffect, match, resolve, State, Task } from '@zeix/cause-effect'
256
+ import { createEffect, Ref } from '@zeix/cause-effect'
423
257
 
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
- })
258
+ const elementRef = new Ref(document.getElementById('status'))
431
259
 
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
- })
260
+ createEffect(() => console.log(elementRef.get().className))
440
261
 
441
- // When id changes, the previous request is automatically canceled
442
- document.querySelector('button.next').addEventListener('click', () => {
443
- id.update(v => ++v)
444
- })
262
+ // external mutation happened
263
+ elementRef.notify()
445
264
  ```
446
265
 
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
266
+ Use `Ref` for DOM nodes, Maps/Sets, sockets, third-party objects, etc.
450
267
 
451
- The `createEffect()` function supports both synchronous and asynchronous callbacks:
268
+ ## Effects
452
269
 
453
- ### Synchronous Effects
270
+ The `createEffect()` callback runs whenever the signals it reads change. It supports sync or async callbacks and returns a cleanup function.
454
271
 
455
272
  ```js
456
- import { createEffect, State } from '@zeix/cause-effect'
273
+ import { State, createEffect } from '@zeix/cause-effect'
457
274
 
458
275
  const count = new State(42)
459
- createEffect(() => {
460
- console.log('Count changed:', count.get())
276
+
277
+ const cleanup = createEffect(() => {
278
+ console.log(count.get())
279
+ return () => console.log('Cleanup')
461
280
  })
462
- ```
463
281
 
464
- ### Asynchronous Effects with AbortSignal
282
+ cleanup()
283
+ ```
465
284
 
466
- Async effect callbacks receive an `AbortSignal` parameter that automatically cancels when the effect re-runs or is cleaned up:
285
+ Async effects receive an AbortSignal that cancels on rerun or cleanup:
467
286
 
468
287
  ```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
- }
288
+ createEffect(async abort => {
289
+ const res = await fetch('/api', { signal: abort })
290
+ if (res.ok) console.log(await res.json())
482
291
  })
483
292
  ```
484
293
 
485
- ### Error Handling with Helper Functions
294
+ ### Error Handling: resolve() + match()
486
295
 
487
- For more sophisticated error handling, use the `resolve()` and `match()` helper functions:
296
+ Use `resolve()` to extract values from signals (including pending/err states) and `match()` to handle them declaratively:
488
297
 
489
298
  ```js
490
- import { createEffect, resolve, match, State } from '@zeix/cause-effect'
299
+ import { State, Task, createEffect, resolve, match } from '@zeix/cause-effect'
491
300
 
492
301
  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()
302
+ const userData = new Task(async (_, abort) => {
303
+ const res = await fetch(`/api/users/${userId.get()}`, { signal: abort })
304
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
305
+ return res.json()
497
306
  })
498
307
 
499
308
  createEffect(() => {
500
309
  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])
310
+ ok: ({ userData: user }) => console.log('User:', user),
311
+ nil: () => console.log('Loading...'),
312
+ err: errors => console.error(errors[0])
504
313
  })
505
314
  })
506
315
  ```
507
316
 
508
- The `resolve()` function extracts values from signals and returns a discriminated union result, while `match()` provides pattern matching for handling different states declaratively.
317
+ ## Signal Type Decision Tree
318
+
319
+ ```
320
+ Is the value managed *inside* the reactive system?
321
+
322
+ ├─ No → Use `Ref`
323
+ │ (DOM nodes, Map/Set, Date, sockets, 3rd-party objects)
324
+ │ Remember: call `.notify()` when it changes externally.
325
+
326
+ └─ Yes? What kind of data is it?
327
+
328
+ ├─ *Primitive* (number/string/boolean)
329
+ │ │
330
+ │ ├─ Do you want to mutate it directly?
331
+ │ │ └─ Yes → `State`
332
+ │ │
333
+ │ └─ Is it derived from other signals?
334
+ │ │
335
+ │ ├─ Sync derived
336
+ │ │ ├─ Simple/cheap → plain function (preferred)
337
+ │ │ └─ Expensive/shared/stateful → `Memo`
338
+ │ │
339
+ │ └─ Async derived → `Task`
340
+ │ (cancellation + memoization + pending/error state)
341
+
342
+ ├─ *Plain Object*
343
+ │ │
344
+ │ ├─ Do you want to mutate individual properties?
345
+ │ │ ├─ Yes → `Store`
346
+ │ │ └─ No, whole object mutations only → `State`
347
+ │ │
348
+ │ └─ Is it derived from other signals?
349
+ │ ├─ Sync derived → plain function or `Memo`
350
+ │ └─ Async derived → `Task`
351
+
352
+ └─ *Array*
353
+
354
+ ├─ Do you need to mutate it (add/remove/sort) with stable item identity?
355
+ │ ├─ Yes → `List`
356
+ │ └─ No, whole array mutations only → `State`
357
+
358
+ └─ Is it derived / read-only transformation of a `List` or `Collection`?
359
+ └─ Yes → `Collection`
360
+ (memoized + supports async mapping + chaining)
361
+ ```
509
362
 
510
363
  ## Advanced Usage
511
364
 
512
- ### Batching Updates
365
+ ### Batching
513
366
 
514
- Use `batchSignalWrites()` to group multiple signal updates, ensuring effects run only once after all changes are applied:
367
+ Group multiple signal updates, ensuring effects run only once after all changes are applied:
515
368
 
516
369
  ```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
- })
370
+ import { batchSignalWrites, State } from '@zeix/cause-effect'
536
371
 
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
- })
372
+ const a = new State(2)
373
+ const b = new State(3)
544
374
 
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
- })
375
+ batchSignalWrites(() => {
376
+ a.set(4)
377
+ b.set(5)
552
378
  })
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
379
  ```
559
380
 
560
- ### Cleanup Functions
381
+ ### Cleanup
561
382
 
562
383
  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
384
 
564
385
  ```js
565
- import { createEffect, State } from '@zeix/cause-effect'
386
+ import { State, createEffect } from '@zeix/cause-effect'
566
387
 
567
388
  const user = new State({ name: 'Alice', age: 30 })
568
389
  const greeting = () => `Hello ${user.get().name}!`
@@ -577,31 +398,25 @@ cleanup() // Logs: 'Cleanup' and unsubscribes from signal `user`
577
398
  user.set({ name: 'Bob', age: 28 }) // Won't trigger the effect anymore
578
399
  ```
579
400
 
580
- ## Helper Functions
581
-
582
- ### `resolve()` - Extract Signal Values
401
+ ### resolve()
583
402
 
584
- The `resolve()` function extracts values from multiple signals and returns a discriminated union result:
403
+ Extract signal values:
585
404
 
586
405
  ```js
587
- import { Memo, resolve, State } from '@zeix/cause-effect'
406
+ import { State, Memo, resolve } from '@zeix/cause-effect'
588
407
 
589
408
  const name = new State('Alice')
590
409
  const age = new Memo(() => 30)
591
410
  const result = resolve({ name, age })
592
411
 
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
- }
412
+ if (result.ok) console.log(result.values.name, result.values.age)
413
+ else if (result.pending) console.log('Loading...')
414
+ else console.error('Errors:', result.errors)
600
415
  ```
601
416
 
602
- ### `match()` - Pattern Matching for Side Effects
417
+ ### match()
603
418
 
604
- The `match()` function provides pattern matching on resolve results for side effects:
419
+ Pattern matching on resolved results for side effects:
605
420
 
606
421
  ```js
607
422
  import { resolve, match } from '@zeix/cause-effect'
@@ -613,9 +428,9 @@ match(resolve({ name, age }), {
613
428
  })
614
429
  ```
615
430
 
616
- ### `diff()` - Compare Object Changes
431
+ ### diff()
617
432
 
618
- The `diff()` function compares two objects and returns detailed information about what changed:
433
+ Compare object changes:
619
434
 
620
435
  ```js
621
436
  import { diff } from '@zeix/cause-effect'
@@ -630,11 +445,9 @@ console.log(changes.change) // { age: 31 }
630
445
  console.log(changes.remove) // { city: UNSET }
631
446
  ```
632
447
 
633
- This function is used internally by stores to efficiently determine what changed and emit appropriate events.
448
+ ### isEqual()
634
449
 
635
- ### `isEqual()` - Deep Equality Comparison
636
-
637
- The `isEqual()` function performs deep equality comparison with circular reference detection:
450
+ Deep equality comparison with circular reference detection:
638
451
 
639
452
  ```js
640
453
  import { isEqual } from '@zeix/cause-effect'
@@ -652,12 +465,10 @@ console.log(isEqual('hello', 'hello')) // true
652
465
  console.log(isEqual({ a: [1, 2] }, { a: [1, 2] })) // true
653
466
  ```
654
467
 
655
- Both `diff()` and `isEqual()` include built-in protection against circular references and will throw a `CircularDependencyError` if cycles are detected.
656
-
657
468
  ## Contributing & License
658
469
 
659
470
  Feel free to contribute, report issues, or suggest improvements.
660
471
 
661
472
  License: [MIT](LICENSE)
662
473
 
663
- (c) 2024 2026 [Zeix AG](https://zeix.com)
474
+ (c) 2024 - 2026 [Zeix AG](https://zeix.com)