@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/.ai-context.md +19 -5
- package/.cursorrules +8 -3
- package/.github/copilot-instructions.md +9 -4
- package/CLAUDE.md +96 -262
- package/README.md +232 -421
- package/archive/computed.ts +2 -2
- package/archive/memo.ts +3 -2
- package/archive/task.ts +2 -2
- package/index.dev.js +59 -26
- package/index.js +1 -1
- package/index.ts +11 -3
- package/package.json +1 -1
- package/src/classes/collection.ts +38 -28
- package/src/classes/computed.ts +3 -3
- package/src/classes/list.ts +8 -7
- package/src/classes/ref.ts +68 -0
- package/src/errors.ts +21 -0
- package/src/match.ts +5 -12
- package/src/resolve.ts +3 -2
- package/src/util.ts +0 -4
- package/test/collection.test.ts +104 -47
- package/test/ref.test.ts +227 -0
- package/types/index.d.ts +5 -4
- package/types/src/classes/collection.d.ts +21 -7
- package/types/src/classes/list.d.ts +4 -4
- package/types/src/classes/ref.d.ts +39 -0
- package/types/src/errors.d.ts +6 -1
- package/types/src/util.d.ts +1 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Cause & Effect
|
|
2
2
|
|
|
3
|
-
Version 0.17.
|
|
3
|
+
Version 0.17.1
|
|
4
4
|
|
|
5
|
-
**Cause & Effect** is a
|
|
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
|
|
14
|
-
- **Memo
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
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
|
-
- ⚡ **
|
|
24
|
-
- 🧩 **Composable
|
|
25
|
-
- ⏱️ **Async
|
|
26
|
-
- 🛡️ **
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
|
|
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
|
|
63
|
+
### State
|
|
64
64
|
|
|
65
|
-
`
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
import { createStore, createEffect } from '@zeix/cause-effect'
|
|
82
|
+
### Memo
|
|
87
83
|
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
settings.add('autoSave', false) // Ignored - autoSave remains true
|
|
102
|
+
**Advanced**: Reducer-style memos:
|
|
139
103
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
console.log(settings.timeout) // undefined
|
|
104
|
+
```js
|
|
105
|
+
import { State, Memo } from '@zeix/cause-effect'
|
|
143
106
|
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
+
A `Task` handles asynchronous computations with cancellation support:
|
|
156
121
|
|
|
157
122
|
```js
|
|
158
|
-
import {
|
|
123
|
+
import { State, Task } from '@zeix/cause-effect'
|
|
159
124
|
|
|
160
|
-
const
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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(
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
//
|
|
254
|
-
|
|
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
|
-
|
|
162
|
+
Dynamic properties using the `add()` and `remove()` methods:
|
|
260
163
|
|
|
261
164
|
```js
|
|
262
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
#### Store Change Notifications
|
|
167
|
+
settings.add('timeout', 5000)
|
|
168
|
+
settings.remove('timeout')
|
|
169
|
+
```
|
|
286
170
|
|
|
287
|
-
|
|
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
|
-
|
|
295
|
-
const offAdd = user.on('add',
|
|
296
|
-
|
|
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:
|
|
311
|
-
user.age.set(31) // Logs: "Changed properties:
|
|
312
|
-
user.remove('email') // Logs: "Removed properties:
|
|
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
|
-
|
|
185
|
+
To stop listening to notifications, call the returned cleanup functions:
|
|
323
186
|
|
|
324
187
|
```js
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
+
const items = new List(['banana', 'apple', 'cherry'])
|
|
341
201
|
|
|
342
|
-
|
|
202
|
+
createEffect(() => console.log(`First: ${items[0].get()}`))
|
|
343
203
|
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
+
A `Collection` is a read-only derived reactive list from `List` or another `Collection`:
|
|
378
225
|
|
|
379
226
|
```js
|
|
380
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
+
### Ref
|
|
415
252
|
|
|
416
|
-
|
|
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,
|
|
256
|
+
import { createEffect, Ref } from '@zeix/cause-effect'
|
|
423
257
|
|
|
424
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
442
|
-
|
|
443
|
-
id.update(v => ++v)
|
|
444
|
-
})
|
|
262
|
+
// external mutation happened
|
|
263
|
+
elementRef.notify()
|
|
445
264
|
```
|
|
446
265
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
## Effects and Error Handling
|
|
266
|
+
Use `Ref` for DOM nodes, Maps/Sets, sockets, third-party objects, etc.
|
|
450
267
|
|
|
451
|
-
|
|
268
|
+
## Effects
|
|
452
269
|
|
|
453
|
-
|
|
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 {
|
|
273
|
+
import { State, createEffect } from '@zeix/cause-effect'
|
|
457
274
|
|
|
458
275
|
const count = new State(42)
|
|
459
|
-
|
|
460
|
-
|
|
276
|
+
|
|
277
|
+
const cleanup = createEffect(() => {
|
|
278
|
+
console.log(count.get())
|
|
279
|
+
return () => console.log('Cleanup')
|
|
461
280
|
})
|
|
462
|
-
```
|
|
463
281
|
|
|
464
|
-
|
|
282
|
+
cleanup()
|
|
283
|
+
```
|
|
465
284
|
|
|
466
|
-
Async
|
|
285
|
+
Async effects receive an AbortSignal that cancels on rerun or cleanup:
|
|
467
286
|
|
|
468
287
|
```js
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
294
|
+
### Error Handling: resolve() + match()
|
|
486
295
|
|
|
487
|
-
|
|
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
|
|
299
|
+
import { State, Task, createEffect, resolve, match } from '@zeix/cause-effect'
|
|
491
300
|
|
|
492
301
|
const userId = new State(1)
|
|
493
|
-
const userData =
|
|
494
|
-
const
|
|
495
|
-
if (!
|
|
496
|
-
return
|
|
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
|
|
502
|
-
nil: () => console.log('Loading
|
|
503
|
-
err: errors => console.error(
|
|
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
|
-
|
|
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
|
|
365
|
+
### Batching
|
|
513
366
|
|
|
514
|
-
|
|
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
|
-
|
|
538
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
### `resolve()` - Extract Signal Values
|
|
401
|
+
### resolve()
|
|
583
402
|
|
|
584
|
-
|
|
403
|
+
Extract signal values:
|
|
585
404
|
|
|
586
405
|
```js
|
|
587
|
-
import { Memo, resolve
|
|
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
|
-
|
|
595
|
-
|
|
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
|
-
###
|
|
417
|
+
### match()
|
|
603
418
|
|
|
604
|
-
|
|
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
|
-
###
|
|
431
|
+
### diff()
|
|
617
432
|
|
|
618
|
-
|
|
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
|
-
|
|
448
|
+
### isEqual()
|
|
634
449
|
|
|
635
|
-
|
|
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
|
|
474
|
+
(c) 2024 - 2026 [Zeix AG](https://zeix.com)
|