@zeix/cause-effect 0.17.3 → 0.18.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 +169 -227
- package/.cursorrules +41 -35
- package/.github/copilot-instructions.md +176 -116
- package/ARCHITECTURE.md +276 -0
- package/CHANGELOG.md +29 -0
- package/CLAUDE.md +201 -143
- package/GUIDE.md +298 -0
- package/README.md +246 -193
- package/REQUIREMENTS.md +100 -0
- package/bench/reactivity.bench.ts +577 -0
- package/context7.json +4 -0
- package/examples/events-sensor.ts +187 -0
- package/examples/selector-sensor.ts +173 -0
- package/index.dev.js +1390 -1008
- package/index.js +1 -1
- package/index.ts +60 -74
- package/package.json +5 -2
- package/skills/changelog-keeper/SKILL.md +59 -0
- package/skills/changelog-keeper/agents/openai.yaml +4 -0
- package/src/errors.ts +118 -74
- package/src/graph.ts +612 -0
- package/src/nodes/collection.ts +512 -0
- package/src/nodes/effect.ts +149 -0
- package/src/nodes/list.ts +589 -0
- package/src/nodes/memo.ts +148 -0
- package/src/nodes/sensor.ts +149 -0
- package/src/nodes/state.ts +135 -0
- package/src/nodes/store.ts +378 -0
- package/src/nodes/task.ts +174 -0
- package/src/signal.ts +112 -66
- package/src/util.ts +26 -57
- package/test/batch.test.ts +96 -62
- package/test/benchmark.test.ts +473 -487
- package/test/collection.test.ts +456 -707
- package/test/effect.test.ts +293 -696
- package/test/list.test.ts +335 -592
- package/test/memo.test.ts +574 -0
- package/test/regression.test.ts +156 -0
- package/test/scope.test.ts +191 -0
- package/test/sensor.test.ts +454 -0
- package/test/signal.test.ts +220 -213
- package/test/state.test.ts +217 -265
- package/test/store.test.ts +346 -446
- package/test/task.test.ts +529 -0
- package/test/untrack.test.ts +167 -0
- package/types/index.d.ts +13 -15
- package/types/src/errors.d.ts +73 -17
- package/types/src/graph.d.ts +218 -0
- package/types/src/nodes/collection.d.ts +69 -0
- package/types/src/nodes/effect.d.ts +48 -0
- package/types/src/nodes/list.d.ts +66 -0
- package/types/src/nodes/memo.d.ts +63 -0
- package/types/src/nodes/sensor.d.ts +81 -0
- package/types/src/nodes/state.d.ts +78 -0
- package/types/src/nodes/store.d.ts +51 -0
- package/types/src/nodes/task.d.ts +79 -0
- package/types/src/signal.d.ts +43 -29
- package/types/src/util.d.ts +9 -16
- package/archive/benchmark.ts +0 -683
- package/archive/collection.ts +0 -253
- package/archive/composite.ts +0 -85
- package/archive/computed.ts +0 -195
- package/archive/list.ts +0 -483
- package/archive/memo.ts +0 -139
- package/archive/state.ts +0 -90
- package/archive/store.ts +0 -298
- package/archive/task.ts +0 -189
- package/src/classes/collection.ts +0 -245
- package/src/classes/computed.ts +0 -349
- package/src/classes/list.ts +0 -343
- package/src/classes/ref.ts +0 -70
- package/src/classes/state.ts +0 -102
- package/src/classes/store.ts +0 -262
- package/src/diff.ts +0 -138
- package/src/effect.ts +0 -93
- package/src/match.ts +0 -45
- package/src/resolve.ts +0 -49
- package/src/system.ts +0 -257
- package/test/computed.test.ts +0 -1108
- package/test/diff.test.ts +0 -955
- package/test/match.test.ts +0 -388
- package/test/ref.test.ts +0 -353
- package/test/resolve.test.ts +0 -154
- package/types/src/classes/collection.d.ts +0 -45
- package/types/src/classes/computed.d.ts +0 -94
- package/types/src/classes/list.d.ts +0 -43
- package/types/src/classes/ref.d.ts +0 -35
- package/types/src/classes/state.d.ts +0 -49
- package/types/src/classes/store.d.ts +0 -52
- package/types/src/diff.d.ts +0 -28
- package/types/src/effect.d.ts +0 -15
- package/types/src/match.d.ts +0 -21
- package/types/src/resolve.d.ts +0 -29
- package/types/src/system.d.ts +0 -78
package/README.md
CHANGED
|
@@ -1,32 +1,40 @@
|
|
|
1
1
|
# Cause & Effect
|
|
2
2
|
|
|
3
|
-
Version 0.
|
|
3
|
+
Version 0.18.1
|
|
4
4
|
|
|
5
|
-
**Cause & Effect** is a
|
|
5
|
+
**Cause & Effect** is a reactive state management primitives library for TypeScript. It provides the foundational building blocks for managing complex, dynamic, composite, and asynchronous state — correctly and performantly — in a unified signal graph.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
It is deliberately **not a framework**. It has no opinions about rendering, persistence, or application architecture. It is a thin, trustworthy layer over JavaScript that provides the comfort and guarantees of fine-grained reactivity while avoiding the common pitfalls of imperative code.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Who Is This For?
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
**Library authors** building on TypeScript — frontend or backend — who need a solid reactive foundation. The library is designed so that consuming libraries do not have to implement their own reactive primitives. Patterns like external data feeds, async derivations, and keyed collections are handled correctly within a unified graph rather than bolted on as ad-hoc extensions.
|
|
12
12
|
|
|
13
|
-
|
|
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()`)
|
|
13
|
+
**Experienced developers** who want to write framework-agnostic applications with explicit dependencies, predictable updates, and type safety. If you are comfortable composing your own rendering and application layers on top of reactive primitives, this library gives you the guarantees without the opinions.
|
|
21
14
|
|
|
22
|
-
|
|
15
|
+
Cause & Effect is open source, built to power **Le Truc** (a Web Component library) by [Zeix AG](https://zeix.com).
|
|
23
16
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
17
|
+
## Signal Types
|
|
18
|
+
|
|
19
|
+
Every signal type participates in the same dependency graph with the same propagation, batching, and cleanup semantics. Each type is justified by a distinct role in the graph and the data structure it manages:
|
|
20
|
+
|
|
21
|
+
| Type | Role | Create with |
|
|
22
|
+
|------|------|-------------|
|
|
23
|
+
| **State** | Mutable source | `createState()` |
|
|
24
|
+
| **Sensor** | External input source (lazy lifecycle) | `createSensor()` |
|
|
25
|
+
| **Memo** | Synchronous derivation (memoized) | `createMemo()` |
|
|
26
|
+
| **Task** | Asynchronous derivation (memoized, cancellable) | `createTask()` |
|
|
27
|
+
| **Store** | Reactive object (keyed properties, proxy-based) | `createStore()` |
|
|
28
|
+
| **List** | Reactive array (keyed items, stable identity) | `createList()` |
|
|
29
|
+
| **Collection** | Reactive collection (external source or derived, item-level memoization) | `createCollection()` |
|
|
30
|
+
| **Effect** | Side-effect sink (terminal) | `createEffect()` |
|
|
31
|
+
|
|
32
|
+
## Design Principles
|
|
33
|
+
|
|
34
|
+
- **Explicit reactivity**: Dependencies are tracked through `.get()` calls — the graph always reflects the true dependency structure, with no hidden subscriptions
|
|
35
|
+
- **Non-nullable types**: All signals enforce `T extends {}`, excluding `null` and `undefined` at the type level — you can trust returned values without null checks
|
|
36
|
+
- **Unified graph**: Composite signals (Store, List, Collection) and async signals (Task) are first-class citizens, not afterthoughts — all derivable state can be derived
|
|
37
|
+
- **Tree-shakable, zero dependencies**: Import only what you use — core signals (State, Memo, Task, Effect) stay below 5 kB gzipped, the full library below 10 kB
|
|
30
38
|
|
|
31
39
|
## Installation
|
|
32
40
|
|
|
@@ -41,13 +49,13 @@ bun add @zeix/cause-effect
|
|
|
41
49
|
## Quick Start
|
|
42
50
|
|
|
43
51
|
```js
|
|
44
|
-
import {
|
|
52
|
+
import { createState, createMemo, createEffect } from '@zeix/cause-effect'
|
|
45
53
|
|
|
46
54
|
// 1. Create state
|
|
47
|
-
const user =
|
|
55
|
+
const user = createState({ name: 'Alice', age: 30 })
|
|
48
56
|
|
|
49
57
|
// 2. Create computed values
|
|
50
|
-
const greeting =
|
|
58
|
+
const greeting = createMemo(() => `Hello ${user.get().name}!`)
|
|
51
59
|
|
|
52
60
|
// 3. React to changes
|
|
53
61
|
createEffect(() => {
|
|
@@ -58,16 +66,16 @@ createEffect(() => {
|
|
|
58
66
|
user.update(u => ({ ...u, age: 31 })) // Logs: "Hello Alice! You are 31 years old"
|
|
59
67
|
```
|
|
60
68
|
|
|
61
|
-
##
|
|
69
|
+
## API
|
|
62
70
|
|
|
63
71
|
### State
|
|
64
72
|
|
|
65
|
-
A
|
|
73
|
+
A mutable source signal. Every signal has a `.get()` method to read its current value. State signals also provide `.set()` to assign a new value and `.update()` to modify it with a function.
|
|
66
74
|
|
|
67
75
|
```js
|
|
68
|
-
import {
|
|
76
|
+
import { createState, createEffect } from '@zeix/cause-effect'
|
|
69
77
|
|
|
70
|
-
const count =
|
|
78
|
+
const count = createState(42)
|
|
71
79
|
|
|
72
80
|
createEffect(() => console.log(count.get()))
|
|
73
81
|
count.set(24)
|
|
@@ -77,17 +85,54 @@ document.querySelector('.increment').addEventListener('click', () => {
|
|
|
77
85
|
})
|
|
78
86
|
```
|
|
79
87
|
|
|
80
|
-
Use
|
|
88
|
+
Use State for primitives or for objects you replace entirely.
|
|
89
|
+
|
|
90
|
+
### Sensor
|
|
91
|
+
|
|
92
|
+
A read-only source that tracks external input. It activates lazily when first accessed by an effect and cleans up when no effects are watching:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
import { createSensor, createEffect } from '@zeix/cause-effect'
|
|
96
|
+
|
|
97
|
+
const mousePos = createSensor((set) => {
|
|
98
|
+
const handler = (e) => set({ x: e.clientX, y: e.clientY })
|
|
99
|
+
window.addEventListener('mousemove', handler)
|
|
100
|
+
return () => window.removeEventListener('mousemove', handler)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
createEffect(() => {
|
|
104
|
+
const pos = mousePos.get()
|
|
105
|
+
if (pos) console.log(`Mouse: ${pos.x}, ${pos.y}`)
|
|
106
|
+
})
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Use Sensor for mouse position, window size, media queries, geolocation, device orientation, or any external value stream.
|
|
110
|
+
|
|
111
|
+
**Observing mutable objects**: Use `SKIP_EQUALITY` when the reference stays the same but internal state changes:
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
import { createSensor, SKIP_EQUALITY, createEffect } from '@zeix/cause-effect'
|
|
115
|
+
|
|
116
|
+
const el = document.getElementById('status')
|
|
117
|
+
const element = createSensor((set) => {
|
|
118
|
+
set(el)
|
|
119
|
+
const observer = new MutationObserver(() => set(el))
|
|
120
|
+
observer.observe(el, { attributes: true, childList: true })
|
|
121
|
+
return () => observer.disconnect()
|
|
122
|
+
}, { value: el, equals: SKIP_EQUALITY })
|
|
123
|
+
|
|
124
|
+
createEffect(() => console.log(element.get().className))
|
|
125
|
+
```
|
|
81
126
|
|
|
82
127
|
### Memo
|
|
83
128
|
|
|
84
|
-
A
|
|
129
|
+
A memoized read-only derivation. It automatically tracks dependencies and updates only when those dependencies actually change.
|
|
85
130
|
|
|
86
131
|
```js
|
|
87
|
-
import {
|
|
132
|
+
import { createState, createMemo, createEffect } from '@zeix/cause-effect'
|
|
88
133
|
|
|
89
|
-
const count =
|
|
90
|
-
const isEven =
|
|
134
|
+
const count = createState(42)
|
|
135
|
+
const isEven = createMemo(() => !(count.get() % 2))
|
|
91
136
|
|
|
92
137
|
createEffect(() => console.log(isEven.get()))
|
|
93
138
|
count.set(24) // no log; still even
|
|
@@ -99,32 +144,32 @@ count.set(24) // no log; still even
|
|
|
99
144
|
const isEven = () => !(count.get() % 2)
|
|
100
145
|
```
|
|
101
146
|
|
|
102
|
-
**Advanced**: Reducer-style memos:
|
|
147
|
+
**Advanced**: Reducer-style memos with previous value access:
|
|
103
148
|
|
|
104
149
|
```js
|
|
105
|
-
import {
|
|
150
|
+
import { createState, createMemo } from '@zeix/cause-effect'
|
|
106
151
|
|
|
107
|
-
const actions =
|
|
108
|
-
const counter =
|
|
152
|
+
const actions = createState('reset')
|
|
153
|
+
const counter = createMemo(prev => {
|
|
109
154
|
switch (actions.get()) {
|
|
110
155
|
case 'increment': return prev + 1
|
|
111
156
|
case 'decrement': return prev - 1
|
|
112
157
|
case 'reset': return 0
|
|
113
158
|
default: return prev
|
|
114
159
|
}
|
|
115
|
-
}, 0)
|
|
160
|
+
}, { value: 0 })
|
|
116
161
|
```
|
|
117
162
|
|
|
118
163
|
### Task
|
|
119
164
|
|
|
120
|
-
|
|
165
|
+
An asynchronous derivation with automatic cancellation. When dependencies change while a computation is in flight, the previous one is aborted:
|
|
121
166
|
|
|
122
167
|
```js
|
|
123
|
-
import {
|
|
168
|
+
import { createState, createTask } from '@zeix/cause-effect'
|
|
124
169
|
|
|
125
|
-
const id =
|
|
170
|
+
const id = createState(1)
|
|
126
171
|
|
|
127
|
-
const data =
|
|
172
|
+
const data = createTask(async (oldValue, abort) => {
|
|
128
173
|
const response = await fetch(`/api/users/${id.get()}`, { signal: abort })
|
|
129
174
|
if (!response.ok) throw new Error('Failed to fetch')
|
|
130
175
|
return response.json()
|
|
@@ -133,11 +178,13 @@ const data = new Task(async (oldValue, abort) => {
|
|
|
133
178
|
id.set(2) // cancels previous fetch automatically
|
|
134
179
|
```
|
|
135
180
|
|
|
136
|
-
|
|
181
|
+
Tasks also provide `.isPending()` to check if a computation is in progress and `.abort()` to manually cancel.
|
|
182
|
+
|
|
183
|
+
Use Task (not plain async functions) when you need memoization, cancellation, and reactive pending/error states.
|
|
137
184
|
|
|
138
185
|
### Store
|
|
139
186
|
|
|
140
|
-
A
|
|
187
|
+
A reactive object where each property becomes its own signal. Nested objects recursively become nested stores. A Proxy provides direct property access:
|
|
141
188
|
|
|
142
189
|
```js
|
|
143
190
|
import { createStore, createEffect } from '@zeix/cause-effect'
|
|
@@ -159,7 +206,7 @@ user.preferences.theme.set('light')
|
|
|
159
206
|
createEffect(() => console.log('User:', user.get()))
|
|
160
207
|
```
|
|
161
208
|
|
|
162
|
-
|
|
209
|
+
Iterate keys using the reactive `.keys()` method to observe structural changes:
|
|
163
210
|
|
|
164
211
|
```js
|
|
165
212
|
for (const key of user.keys()) {
|
|
@@ -167,9 +214,9 @@ for (const key of user.keys()) {
|
|
|
167
214
|
}
|
|
168
215
|
```
|
|
169
216
|
|
|
170
|
-
Access
|
|
217
|
+
Access properties by key using `.byKey()` or via direct property access like `user.name` (enabled by the Proxy).
|
|
171
218
|
|
|
172
|
-
Dynamic properties
|
|
219
|
+
Dynamic properties with `.add()` and `.remove()`:
|
|
173
220
|
|
|
174
221
|
```js
|
|
175
222
|
const settings = createStore({ autoSave: true })
|
|
@@ -180,14 +227,14 @@ settings.remove('timeout')
|
|
|
180
227
|
|
|
181
228
|
### List
|
|
182
229
|
|
|
183
|
-
A
|
|
230
|
+
A reactive array with individually reactive items and stable keys. Each item becomes its own signal while maintaining persistent identity through sorting and reordering:
|
|
184
231
|
|
|
185
232
|
```js
|
|
186
|
-
import {
|
|
233
|
+
import { createList, createEffect } from '@zeix/cause-effect'
|
|
187
234
|
|
|
188
|
-
const items =
|
|
235
|
+
const items = createList(['banana', 'apple', 'cherry'])
|
|
189
236
|
|
|
190
|
-
createEffect(() => console.log(`First: ${items
|
|
237
|
+
createEffect(() => console.log(`First: ${items.at(0)?.get()}`))
|
|
191
238
|
|
|
192
239
|
items.add('date')
|
|
193
240
|
items.splice(1, 1, 'orange')
|
|
@@ -196,73 +243,93 @@ items.sort()
|
|
|
196
243
|
|
|
197
244
|
Access items by key using `.byKey()` or by index using `.at()`. `.indexOfKey()` returns the current index of an item in the list, while `.keyAt()` returns the key of an item at a given position.
|
|
198
245
|
|
|
199
|
-
Keys are stable across reordering:
|
|
246
|
+
Keys are stable across reordering. Use `keyConfig` in options to control key generation:
|
|
200
247
|
|
|
201
248
|
```js
|
|
202
|
-
|
|
203
|
-
const
|
|
249
|
+
// String prefix keys
|
|
250
|
+
const items = createList(['banana', 'apple'], { keyConfig: 'item-' })
|
|
251
|
+
// Creates keys: 'item-0', 'item-1'
|
|
252
|
+
|
|
253
|
+
// Function-based keys
|
|
254
|
+
const users = createList(
|
|
255
|
+
[{ id: 'alice', name: 'Alice' }],
|
|
256
|
+
{ keyConfig: user => user.id }
|
|
257
|
+
)
|
|
204
258
|
|
|
259
|
+
const key = items.add('orange')
|
|
205
260
|
items.sort()
|
|
206
|
-
console.log(items.byKey(key))
|
|
207
|
-
console.log(items.indexOfKey(key))
|
|
261
|
+
console.log(items.byKey(key)?.get()) // 'orange'
|
|
262
|
+
console.log(items.indexOfKey(key)) // current index
|
|
208
263
|
```
|
|
209
264
|
|
|
210
|
-
Lists have `.keys()`, `.add()`, and `.remove()` methods like stores. Additionally, they have `.sort()`, `.splice()`, and a reactive `.length` property. But unlike stores, deeply nested properties in items are not converted to individual signals.
|
|
265
|
+
Lists have `.keys()`, `.add()`, and `.remove()` methods like stores. Additionally, they have `.sort()`, `.splice()`, and a reactive `.length` property. But unlike stores, deeply nested properties in items are not converted to individual signals.
|
|
211
266
|
|
|
212
267
|
### Collection
|
|
213
268
|
|
|
214
|
-
A
|
|
269
|
+
A reactive collection with item-level memoization. Collections can be externally-driven (via a watched callback) or derived from a List or another Collection.
|
|
270
|
+
|
|
271
|
+
**Externally-driven collections** receive data from external sources (WebSocket, Server-Sent Events, etc.) via `applyChanges()`:
|
|
215
272
|
|
|
216
273
|
```js
|
|
217
|
-
import {
|
|
274
|
+
import { createCollection, createEffect } from '@zeix/cause-effect'
|
|
275
|
+
|
|
276
|
+
const items = createCollection((applyChanges) => {
|
|
277
|
+
const ws = new WebSocket('/items')
|
|
278
|
+
ws.onmessage = (e) => {
|
|
279
|
+
const { add, change, remove } = JSON.parse(e.data)
|
|
280
|
+
applyChanges({ add, change, remove })
|
|
281
|
+
}
|
|
282
|
+
return () => ws.close()
|
|
283
|
+
}, { keyConfig: item => item.id })
|
|
284
|
+
|
|
285
|
+
createEffect(() => console.log('Items:', items.get()))
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
The watched callback activates lazily when the collection is first accessed by an effect and cleans up when no effects are watching. Options include `value` for initial items (default `[]`) and `keyConfig` for key generation.
|
|
289
|
+
|
|
290
|
+
**Derived collections** transform Lists or other Collections via `.deriveCollection()`:
|
|
218
291
|
|
|
219
|
-
|
|
292
|
+
```js
|
|
293
|
+
import { createList } from '@zeix/cause-effect'
|
|
294
|
+
|
|
295
|
+
const users = createList([
|
|
220
296
|
{ id: 1, name: 'Alice', role: 'admin' },
|
|
221
297
|
{ id: 2, name: 'Bob', role: 'user' }
|
|
222
|
-
])
|
|
298
|
+
], { keyConfig: u => String(u.id) })
|
|
299
|
+
|
|
223
300
|
const profiles = users.deriveCollection(user => ({
|
|
224
301
|
...user,
|
|
225
302
|
displayName: `${user.name} (${user.role})`
|
|
226
303
|
}))
|
|
227
304
|
|
|
228
|
-
|
|
229
|
-
console.log(userProfiles.at(0).get().displayName)
|
|
305
|
+
console.log(profiles.at(0)?.get().displayName)
|
|
230
306
|
```
|
|
231
307
|
|
|
232
|
-
Async mapping is supported:
|
|
308
|
+
Async mapping is supported:
|
|
233
309
|
|
|
234
310
|
```js
|
|
235
|
-
const details = users.
|
|
311
|
+
const details = users.deriveCollection(async (user, abort) => {
|
|
236
312
|
const response = await fetch(`/users/${user.id}`, { signal: abort })
|
|
237
313
|
return { ...user, details: await response.json() }
|
|
238
314
|
})
|
|
239
315
|
```
|
|
240
316
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
A `Ref` is a signal that holds a reference to an external object that can change outside the reactive system.
|
|
317
|
+
Collections can be chained for data pipelines:
|
|
244
318
|
|
|
245
319
|
```js
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
createEffect(() => console.log(elementRef.get().className))
|
|
251
|
-
|
|
252
|
-
// external mutation happened
|
|
253
|
-
elementRef.notify()
|
|
320
|
+
const processed = users
|
|
321
|
+
.deriveCollection(user => ({ ...user, active: user.lastLogin > threshold }))
|
|
322
|
+
.deriveCollection(user => user.active ? `Active: ${user.name}` : `Inactive: ${user.name}`)
|
|
254
323
|
```
|
|
255
324
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
## Effects
|
|
325
|
+
### Effect
|
|
259
326
|
|
|
260
|
-
|
|
327
|
+
A side-effect sink that runs whenever the signals it reads change. Effects are terminal — they consume values but produce none. The returned function disposes the effect:
|
|
261
328
|
|
|
262
329
|
```js
|
|
263
|
-
import {
|
|
330
|
+
import { createState, createEffect } from '@zeix/cause-effect'
|
|
264
331
|
|
|
265
|
-
const count =
|
|
332
|
+
const count = createState(42)
|
|
266
333
|
|
|
267
334
|
const cleanup = createEffect(() => {
|
|
268
335
|
console.log(count.get())
|
|
@@ -272,81 +339,84 @@ const cleanup = createEffect(() => {
|
|
|
272
339
|
cleanup()
|
|
273
340
|
```
|
|
274
341
|
|
|
275
|
-
|
|
342
|
+
Effect callbacks can return a cleanup function that runs before the effect re-runs or when disposed:
|
|
276
343
|
|
|
277
344
|
```js
|
|
278
|
-
createEffect(
|
|
279
|
-
const
|
|
280
|
-
|
|
345
|
+
createEffect(() => {
|
|
346
|
+
const timer = setInterval(() => console.log(count.get()), 1000)
|
|
347
|
+
return () => clearInterval(timer)
|
|
281
348
|
})
|
|
282
349
|
```
|
|
283
350
|
|
|
284
|
-
|
|
351
|
+
#### Error Handling: match()
|
|
285
352
|
|
|
286
|
-
Use `
|
|
353
|
+
Use `match()` inside effects to handle signal values declaratively, including pending and error states from Tasks:
|
|
287
354
|
|
|
288
355
|
```js
|
|
289
|
-
import {
|
|
356
|
+
import { createState, createTask, createEffect, match } from '@zeix/cause-effect'
|
|
290
357
|
|
|
291
|
-
const userId =
|
|
292
|
-
const userData =
|
|
358
|
+
const userId = createState(1)
|
|
359
|
+
const userData = createTask(async (_, abort) => {
|
|
293
360
|
const res = await fetch(`/api/users/${userId.get()}`, { signal: abort })
|
|
294
361
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
295
362
|
return res.json()
|
|
296
363
|
})
|
|
297
364
|
|
|
298
365
|
createEffect(() => {
|
|
299
|
-
match(
|
|
300
|
-
ok: (
|
|
366
|
+
match([userData], {
|
|
367
|
+
ok: ([user]) => console.log('User:', user),
|
|
301
368
|
nil: () => console.log('Loading...'),
|
|
302
369
|
err: errors => console.error(errors[0])
|
|
303
370
|
})
|
|
304
371
|
})
|
|
305
372
|
```
|
|
306
373
|
|
|
307
|
-
##
|
|
374
|
+
## Choosing the Right Signal
|
|
308
375
|
|
|
309
376
|
```
|
|
310
|
-
|
|
377
|
+
Does the data come from *outside* the reactive system?
|
|
378
|
+
│
|
|
379
|
+
├─ Yes, single value → `createSensor(set => { ... })`
|
|
380
|
+
│ (mouse position, window resize, media queries, DOM observers, etc.)
|
|
381
|
+
│ Tip: Use `{ equals: SKIP_EQUALITY }` for mutable object observation
|
|
311
382
|
│
|
|
312
|
-
├─
|
|
313
|
-
│
|
|
314
|
-
│ Remember: call `.notify()` when it changes externally.
|
|
383
|
+
├─ Yes, keyed collection → `createCollection(applyChanges => { ... })`
|
|
384
|
+
│ (WebSocket streams, Server-Sent Events, external data feeds, etc.)
|
|
315
385
|
│
|
|
316
|
-
└─
|
|
386
|
+
└─ No, managed internally? What kind of data is it?
|
|
317
387
|
│
|
|
318
388
|
├─ *Primitive* (number/string/boolean)
|
|
319
389
|
│ │
|
|
320
390
|
│ ├─ Do you want to mutate it directly?
|
|
321
|
-
│ │ └─ Yes → `
|
|
391
|
+
│ │ └─ Yes → `createState()`
|
|
322
392
|
│ │
|
|
323
393
|
│ └─ Is it derived from other signals?
|
|
324
394
|
│ │
|
|
325
395
|
│ ├─ Sync derived
|
|
326
396
|
│ │ ├─ Simple/cheap → plain function (preferred)
|
|
327
|
-
│ │ └─ Expensive/shared/stateful → `
|
|
328
|
-
│ │
|
|
329
|
-
│ └─ Async derived → `
|
|
397
|
+
│ │ └─ Expensive/shared/stateful → `createMemo()`
|
|
398
|
+
│ │
|
|
399
|
+
│ └─ Async derived → `createTask()`
|
|
330
400
|
│ (cancellation + memoization + pending/error state)
|
|
331
401
|
│
|
|
332
402
|
├─ *Plain Object*
|
|
333
403
|
│ │
|
|
334
404
|
│ ├─ Do you want to mutate individual properties?
|
|
335
|
-
│ │ ├─ Yes → `
|
|
336
|
-
│ │ └─ No, whole object mutations only → `
|
|
405
|
+
│ │ ├─ Yes → `createStore()`
|
|
406
|
+
│ │ └─ No, whole object mutations only → `createState()`
|
|
337
407
|
│ │
|
|
338
408
|
│ └─ Is it derived from other signals?
|
|
339
|
-
│ ├─ Sync derived → plain function or `
|
|
340
|
-
│ └─ Async derived → `
|
|
409
|
+
│ ├─ Sync derived → plain function or `createMemo()`
|
|
410
|
+
│ └─ Async derived → `createTask()`
|
|
341
411
|
│
|
|
342
412
|
└─ *Array*
|
|
343
413
|
│
|
|
344
414
|
├─ Do you need to mutate it (add/remove/sort) with stable item identity?
|
|
345
|
-
│ ├─ Yes → `
|
|
346
|
-
│ └─ No, whole array mutations only → `
|
|
415
|
+
│ ├─ Yes → `createList()`
|
|
416
|
+
│ └─ No, whole array mutations only → `createState()`
|
|
347
417
|
│
|
|
348
418
|
└─ Is it derived / read-only transformation of a `List` or `Collection`?
|
|
349
|
-
└─ Yes → `
|
|
419
|
+
└─ Yes → `.deriveCollection()`
|
|
350
420
|
(memoized + supports async mapping + chaining)
|
|
351
421
|
```
|
|
352
422
|
|
|
@@ -357,12 +427,12 @@ Is the value managed *inside* the reactive system?
|
|
|
357
427
|
Group multiple signal updates, ensuring effects run only once after all changes are applied:
|
|
358
428
|
|
|
359
429
|
```js
|
|
360
|
-
import {
|
|
430
|
+
import { batch, createState } from '@zeix/cause-effect'
|
|
361
431
|
|
|
362
|
-
const a =
|
|
363
|
-
const b =
|
|
432
|
+
const a = createState(2)
|
|
433
|
+
const b = createState(3)
|
|
364
434
|
|
|
365
|
-
|
|
435
|
+
batch(() => {
|
|
366
436
|
a.set(4)
|
|
367
437
|
b.set(5)
|
|
368
438
|
})
|
|
@@ -370,16 +440,16 @@ batchSignalWrites(() => {
|
|
|
370
440
|
|
|
371
441
|
### Cleanup
|
|
372
442
|
|
|
373
|
-
Effects return a cleanup function. When executed, it will unsubscribe from signals and run cleanup functions returned by effect callbacks
|
|
443
|
+
Effects return a cleanup function. When executed, it will unsubscribe from signals and run cleanup functions returned by effect callbacks.
|
|
374
444
|
|
|
375
445
|
```js
|
|
376
|
-
import {
|
|
446
|
+
import { createState, createEffect } from '@zeix/cause-effect'
|
|
377
447
|
|
|
378
|
-
const user =
|
|
448
|
+
const user = createState({ name: 'Alice', age: 30 })
|
|
379
449
|
const greeting = () => `Hello ${user.get().name}!`
|
|
380
450
|
const cleanup = createEffect(() => {
|
|
381
|
-
|
|
382
|
-
|
|
451
|
+
console.log(`${greeting()} You are ${user.get().age} years old`)
|
|
452
|
+
return () => console.log('Cleanup')
|
|
383
453
|
})
|
|
384
454
|
|
|
385
455
|
// When you no longer need the effect, execute the cleanup function
|
|
@@ -388,105 +458,88 @@ cleanup() // Logs: 'Cleanup' and unsubscribes from signal `user`
|
|
|
388
458
|
user.set({ name: 'Bob', age: 28 }) // Won't trigger the effect anymore
|
|
389
459
|
```
|
|
390
460
|
|
|
391
|
-
###
|
|
461
|
+
### Scoped Cleanup
|
|
392
462
|
|
|
393
|
-
|
|
463
|
+
Use `createScope()` for hierarchical cleanup of nested effects and resources. It returns a single cleanup function:
|
|
394
464
|
|
|
395
465
|
```js
|
|
396
|
-
import {
|
|
466
|
+
import { createState, createEffect, createScope } from '@zeix/cause-effect'
|
|
397
467
|
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
},
|
|
403
|
-
unwatched: () => {
|
|
404
|
-
console.log('Cleaning up API client...')
|
|
405
|
-
client.disconnect()
|
|
406
|
-
}
|
|
468
|
+
const dispose = createScope(() => {
|
|
469
|
+
const count = createState(0)
|
|
470
|
+
createEffect(() => console.log(count.get()))
|
|
471
|
+
return () => console.log('Scope disposed')
|
|
407
472
|
})
|
|
408
473
|
|
|
409
|
-
//
|
|
410
|
-
const cleanup = createEffect(() => {
|
|
411
|
-
console.log('API URL:', config.get().apiUrl)
|
|
412
|
-
})
|
|
413
|
-
|
|
414
|
-
// Resource is cleaned up when effect stops
|
|
415
|
-
cleanup()
|
|
474
|
+
dispose() // Cleans up the effect and runs the returned cleanup
|
|
416
475
|
```
|
|
417
476
|
|
|
418
|
-
|
|
419
|
-
- Event listeners that should only be active when data is being watched
|
|
420
|
-
- Network connections that can be lazily established
|
|
421
|
-
- Expensive computations that should pause when not needed
|
|
422
|
-
- External subscriptions (WebSocket, Server-Sent Events, etc.)
|
|
423
|
-
|
|
424
|
-
### resolve()
|
|
477
|
+
### Resource Management with Watch Callbacks
|
|
425
478
|
|
|
426
|
-
|
|
479
|
+
Sensor and Collection signals use a **watched callback** for lazy resource management. The callback runs when the signal is first accessed by an effect and the returned cleanup function runs when no effects are watching:
|
|
427
480
|
|
|
428
481
|
```js
|
|
429
|
-
import {
|
|
482
|
+
import { createSensor, createCollection, createEffect } from '@zeix/cause-effect'
|
|
483
|
+
|
|
484
|
+
// Sensor: track external input
|
|
485
|
+
const windowSize = createSensor((set) => {
|
|
486
|
+
const update = () => set({ w: innerWidth, h: innerHeight })
|
|
487
|
+
update()
|
|
488
|
+
window.addEventListener('resize', update)
|
|
489
|
+
return () => window.removeEventListener('resize', update)
|
|
490
|
+
})
|
|
430
491
|
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
const
|
|
492
|
+
// Collection: receive external data
|
|
493
|
+
const feed = createCollection((applyChanges) => {
|
|
494
|
+
const es = new EventSource('/feed')
|
|
495
|
+
es.onmessage = (e) => applyChanges(JSON.parse(e.data))
|
|
496
|
+
return () => es.close()
|
|
497
|
+
}, { keyConfig: item => item.id })
|
|
434
498
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
499
|
+
// Resources are created only when effect runs
|
|
500
|
+
const cleanup = createEffect(() => {
|
|
501
|
+
console.log('Window size:', windowSize.get())
|
|
502
|
+
console.log('Feed items:', feed.get())
|
|
503
|
+
})
|
|
439
504
|
|
|
440
|
-
|
|
505
|
+
// Resources are cleaned up when effect stops
|
|
506
|
+
cleanup()
|
|
507
|
+
```
|
|
441
508
|
|
|
442
|
-
|
|
509
|
+
Store and List signals support an optional `watched` callback in their options that returns a cleanup function:
|
|
443
510
|
|
|
444
511
|
```js
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
err: errors => document.title = `Error: ${errors[0].message}`
|
|
512
|
+
const user = createStore({ name: 'Alice' }, {
|
|
513
|
+
watched: () => {
|
|
514
|
+
const ws = new WebSocket('/updates')
|
|
515
|
+
return () => ws.close()
|
|
516
|
+
}
|
|
451
517
|
})
|
|
452
518
|
```
|
|
453
519
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
Compare object changes:
|
|
520
|
+
Memo and Task signals also support a `watched` option, but their callback receives an `invalidate` function that marks the signal dirty and triggers recomputation:
|
|
457
521
|
|
|
458
522
|
```js
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
523
|
+
const changes = createMemo((prev) => {
|
|
524
|
+
const next = new Set(parent.querySelectorAll(selector))
|
|
525
|
+
// ... diff prev vs next ...
|
|
526
|
+
return { current: next, added, removed }
|
|
527
|
+
}, {
|
|
528
|
+
value: { current: new Set(), added: [], removed: [] },
|
|
529
|
+
watched: (invalidate) => {
|
|
530
|
+
const observer = new MutationObserver(() => invalidate())
|
|
531
|
+
observer.observe(parent, { childList: true, subtree: true })
|
|
532
|
+
return () => observer.disconnect()
|
|
533
|
+
}
|
|
534
|
+
})
|
|
469
535
|
```
|
|
470
536
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const obj1 = { name: 'Alice', preferences: { theme: 'dark' } }
|
|
479
|
-
const obj2 = { name: 'Alice', preferences: { theme: 'dark' } }
|
|
480
|
-
const obj3 = { name: 'Bob', preferences: { theme: 'dark' } }
|
|
481
|
-
|
|
482
|
-
console.log(isEqual(obj1, obj2)) // true - deep equality
|
|
483
|
-
console.log(isEqual(obj1, obj3)) // false - names differ
|
|
484
|
-
|
|
485
|
-
// Handles arrays, primitives, and complex nested structures
|
|
486
|
-
console.log(isEqual([1, 2, 3], [1, 2, 3])) // true
|
|
487
|
-
console.log(isEqual('hello', 'hello')) // true
|
|
488
|
-
console.log(isEqual({ a: [1, 2] }, { a: [1, 2] })) // true
|
|
489
|
-
```
|
|
537
|
+
This pattern is ideal for:
|
|
538
|
+
- Event listeners that should only be active when data is being watched
|
|
539
|
+
- Network connections that can be lazily established
|
|
540
|
+
- Expensive computations that should pause when not needed
|
|
541
|
+
- External subscriptions (WebSocket, Server-Sent Events, etc.)
|
|
542
|
+
- Computed signals that need to react to external events (DOM mutations, timers)
|
|
490
543
|
|
|
491
544
|
## Contributing & License
|
|
492
545
|
|