@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.
- package/.ai-context.md +26 -5
- package/.cursorrules +8 -3
- package/.github/copilot-instructions.md +13 -4
- package/CLAUDE.md +191 -262
- package/README.md +268 -420
- package/archive/collection.ts +23 -25
- package/archive/computed.ts +5 -4
- package/archive/list.ts +21 -28
- package/archive/memo.ts +4 -2
- package/archive/state.ts +2 -1
- package/archive/store.ts +21 -32
- package/archive/task.ts +6 -9
- package/index.dev.js +411 -220
- package/index.js +1 -1
- package/index.ts +25 -8
- package/package.json +1 -1
- package/src/classes/collection.ts +103 -77
- package/src/classes/composite.ts +28 -33
- package/src/classes/computed.ts +90 -31
- package/src/classes/list.ts +39 -33
- package/src/classes/ref.ts +96 -0
- package/src/classes/state.ts +41 -8
- package/src/classes/store.ts +47 -30
- package/src/diff.ts +2 -1
- package/src/effect.ts +19 -9
- package/src/errors.ts +31 -1
- package/src/match.ts +5 -12
- package/src/resolve.ts +3 -2
- package/src/signal.ts +0 -1
- package/src/system.ts +159 -43
- package/src/util.ts +0 -10
- package/test/collection.test.ts +383 -67
- package/test/computed.test.ts +268 -11
- package/test/effect.test.ts +2 -2
- package/test/list.test.ts +249 -21
- package/test/ref.test.ts +381 -0
- package/test/state.test.ts +13 -13
- package/test/store.test.ts +473 -28
- package/types/index.d.ts +6 -5
- package/types/src/classes/collection.d.ts +27 -12
- package/types/src/classes/composite.d.ts +4 -4
- package/types/src/classes/computed.d.ts +17 -0
- package/types/src/classes/list.d.ts +6 -6
- package/types/src/classes/ref.d.ts +48 -0
- package/types/src/classes/state.d.ts +9 -0
- package/types/src/classes/store.d.ts +4 -4
- package/types/src/effect.d.ts +1 -2
- package/types/src/errors.d.ts +9 -1
- package/types/src/system.d.ts +40 -24
- 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.
|
|
3
|
+
Version 0.17.2
|
|
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,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
|
|
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
|
-
|
|
80
|
+
Use `State` for primitives or for objects you typically replace entirely.
|
|
82
81
|
|
|
83
|
-
|
|
82
|
+
### Memo
|
|
84
83
|
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
118
|
+
### Task
|
|
152
119
|
|
|
153
|
-
|
|
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 {
|
|
159
|
-
|
|
160
|
-
const items = new List(['banana', 'apple', 'cherry'])
|
|
123
|
+
import { State, Task } from '@zeix/cause-effect'
|
|
161
124
|
|
|
162
|
-
|
|
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'
|
|
136
|
+
**Note**: Use Task (not plain async functions) when you want memoization + cancellation + reactive pending/error states.
|
|
202
137
|
|
|
203
|
-
|
|
204
|
-
[{ id: 'bob', name: 'Bob' }, { id: 'alice', name: 'Alice' }],
|
|
205
|
-
user => user.id
|
|
206
|
-
)
|
|
138
|
+
### Store
|
|
207
139
|
|
|
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' }]
|
|
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 {
|
|
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
|
|
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
|
-
|
|
167
|
+
settings.add('timeout', 5000)
|
|
168
|
+
settings.remove('timeout')
|
|
169
|
+
```
|
|
286
170
|
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
186
|
+
To unregister hooks, call the returned cleanup functions:
|
|
323
187
|
|
|
324
188
|
```js
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
//
|
|
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
|
-
|
|
194
|
+
### List
|
|
332
195
|
|
|
333
|
-
|
|
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
|
-
|
|
198
|
+
```js
|
|
199
|
+
import { List, createEffect } from '@zeix/cause-effect'
|
|
341
200
|
|
|
342
|
-
|
|
201
|
+
const items = new List(['banana', 'apple', 'cherry'])
|
|
343
202
|
|
|
344
|
-
|
|
345
|
-
import { createEffect, Memo, State } from '@zeix/cause-effect'
|
|
203
|
+
createEffect(() => console.log(`First: ${items[0].get()}`))
|
|
346
204
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
+
A `Collection` is a read-only derived reactive list from `List` or another `Collection`:
|
|
378
226
|
|
|
379
227
|
```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
|
|
228
|
+
import { List, createEffect } from '@zeix/cause-effect'
|
|
396
229
|
|
|
397
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
+
### Ref
|
|
415
253
|
|
|
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
|
|
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,
|
|
257
|
+
import { createEffect, Ref } from '@zeix/cause-effect'
|
|
423
258
|
|
|
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
|
-
})
|
|
259
|
+
const elementRef = new Ref(document.getElementById('status'))
|
|
431
260
|
|
|
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
|
-
})
|
|
261
|
+
createEffect(() => console.log(elementRef.get().className))
|
|
440
262
|
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
id.update(v => ++v)
|
|
444
|
-
})
|
|
263
|
+
// external mutation happened
|
|
264
|
+
elementRef.notify()
|
|
445
265
|
```
|
|
446
266
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
## Effects and Error Handling
|
|
267
|
+
Use `Ref` for DOM nodes, Maps/Sets, sockets, third-party objects, etc.
|
|
450
268
|
|
|
451
|
-
|
|
269
|
+
## Effects
|
|
452
270
|
|
|
453
|
-
|
|
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 {
|
|
274
|
+
import { State, createEffect } from '@zeix/cause-effect'
|
|
457
275
|
|
|
458
276
|
const count = new State(42)
|
|
459
|
-
|
|
460
|
-
|
|
277
|
+
|
|
278
|
+
const cleanup = createEffect(() => {
|
|
279
|
+
console.log(count.get())
|
|
280
|
+
return () => console.log('Cleanup')
|
|
461
281
|
})
|
|
462
|
-
```
|
|
463
282
|
|
|
464
|
-
|
|
283
|
+
cleanup()
|
|
284
|
+
```
|
|
465
285
|
|
|
466
|
-
Async
|
|
286
|
+
Async effects receive an AbortSignal that cancels on rerun or cleanup:
|
|
467
287
|
|
|
468
288
|
```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
|
-
}
|
|
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
|
|
295
|
+
### Error Handling: resolve() + match()
|
|
486
296
|
|
|
487
|
-
|
|
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
|
|
300
|
+
import { State, Task, createEffect, resolve, match } from '@zeix/cause-effect'
|
|
491
301
|
|
|
492
302
|
const userId = new State(1)
|
|
493
|
-
const userData =
|
|
494
|
-
const
|
|
495
|
-
if (!
|
|
496
|
-
return
|
|
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
|
|
502
|
-
nil: () => console.log('Loading
|
|
503
|
-
err: errors => console.error(
|
|
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
|
-
|
|
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
|
|
366
|
+
### Batching
|
|
513
367
|
|
|
514
|
-
|
|
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
|
-
|
|
538
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
406
|
+
```js
|
|
407
|
+
import { State, createEffect } from '@zeix/cause-effect'
|
|
583
408
|
|
|
584
|
-
|
|
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
|
|
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
|
-
|
|
595
|
-
|
|
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
|
-
###
|
|
454
|
+
### match()
|
|
603
455
|
|
|
604
|
-
|
|
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
|
-
###
|
|
468
|
+
### diff()
|
|
617
469
|
|
|
618
|
-
|
|
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
|
-
|
|
485
|
+
### isEqual()
|
|
634
486
|
|
|
635
|
-
|
|
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
|
|
511
|
+
(c) 2024 - 2026 [Zeix AG](https://zeix.com)
|