@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/GUIDE.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# Guide for Framework Developers
|
|
2
|
+
|
|
3
|
+
If you've used React, Vue, or Angular, you already understand the core idea behind Cause & Effect: state changes should automatically propagate to derived values and side effects. This guide maps what you know to how this library works, explains where the mental model diverges, and introduces capabilities that go beyond what most reactive libraries provide.
|
|
4
|
+
|
|
5
|
+
## The Familiar Core
|
|
6
|
+
|
|
7
|
+
The three building blocks map directly to what you already use:
|
|
8
|
+
|
|
9
|
+
| Concept | React | Vue | Angular | Cause & Effect |
|
|
10
|
+
|---------|-------|-----|---------|----------------|
|
|
11
|
+
| Mutable state | `useState` | `ref()` | `signal()` | `createState()` |
|
|
12
|
+
| Derived value | `useMemo` | `computed()` | `computed()` | `createMemo()` |
|
|
13
|
+
| Side effect | `useEffect` | `watchEffect()` | `effect()` | `createEffect()` |
|
|
14
|
+
|
|
15
|
+
Here is how they work together:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createState, createMemo, createEffect } from '@zeix/cause-effect'
|
|
19
|
+
|
|
20
|
+
const count = createState(0)
|
|
21
|
+
const doubled = createMemo(() => count.get() * 2)
|
|
22
|
+
|
|
23
|
+
createEffect(() => {
|
|
24
|
+
console.log(`${count.get()} doubled is ${doubled.get()}`)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
count.set(5) // logs: "5 doubled is 10"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If you've written a `computed` in Vue or a `useMemo` in React, this should feel immediately familiar. The difference is that there is no component, no template, no JSX — just reactive primitives composing directly.
|
|
31
|
+
|
|
32
|
+
## What Works Differently
|
|
33
|
+
|
|
34
|
+
### Dependencies are tracked, not declared
|
|
35
|
+
|
|
36
|
+
In React, you declare dependencies manually:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// React
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
console.log(count)
|
|
42
|
+
}, [count]) // ← you must list dependencies
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
In Cause & Effect, calling `.get()` *is* the dependency declaration. If you read a signal inside an effect or memo, it becomes a dependency automatically. If you don't read it, it doesn't.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// Cause & Effect
|
|
49
|
+
createEffect(() => {
|
|
50
|
+
console.log(count.get()) // ← this IS the dependency
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
There are no dependency arrays to maintain, no lint rules to enforce them, and no stale closure bugs from forgetting a dependency. Vue and Angular developers will find this familiar — it works like `watchEffect()` and Angular's `effect()`.
|
|
55
|
+
|
|
56
|
+
### Effects run synchronously
|
|
57
|
+
|
|
58
|
+
In React, effects run after the browser paints. In Vue, reactive updates are batched until the next microtask. In Cause & Effect, effects run synchronously right after a state change:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const name = createState('Alice')
|
|
62
|
+
|
|
63
|
+
createEffect(() => {
|
|
64
|
+
console.log(name.get()) // runs immediately with "Alice"
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
name.set('Bob') // runs the effect again, right here, synchronously
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
When you need to update multiple signals without triggering intermediate effects, wrap updates in `batch()`:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { batch } from '@zeix/cause-effect'
|
|
74
|
+
|
|
75
|
+
batch(() => {
|
|
76
|
+
firstName.set('Bob')
|
|
77
|
+
lastName.set('Smith')
|
|
78
|
+
}) // effect runs once, after both updates
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Non-nullable signals
|
|
82
|
+
|
|
83
|
+
All signals enforce `T extends {}` — `null` and `undefined` are excluded at the type level. This means you can trust that `.get()` always returns a real value without null checks.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const count = createState(0)
|
|
87
|
+
count.get() // type is number, guaranteed non-null
|
|
88
|
+
|
|
89
|
+
// This won't compile:
|
|
90
|
+
// const maybeUser = createState<User | null>(null)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This is a deliberate design decision. In frameworks, nullable state leads to defensive checks scattered across templates and hooks. Here, the type system prevents it.
|
|
94
|
+
|
|
95
|
+
**What to do instead:**
|
|
96
|
+
|
|
97
|
+
- For async results: use `createTask()` — a Task without reactive dependencies works like a Promise that resolves into the graph. Use `match()` to handle the pending state.
|
|
98
|
+
- For external input that starts undefined: use `createSensor()` with its lazy start callback.
|
|
99
|
+
- For optional state: use a discriminated union, an empty string, an empty array, `0`, or `false` — whatever the zero value for your type is:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
type AuthState = { status: 'anonymous' } | { status: 'authenticated', user: User }
|
|
103
|
+
const auth = createState<AuthState>({ status: 'anonymous' })
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Scopes replace the component tree
|
|
107
|
+
|
|
108
|
+
In React, Vue, and Angular, reactivity is tied to components. Effects clean up when components unmount. Components form a tree that manages lifetimes.
|
|
109
|
+
|
|
110
|
+
Cause & Effect has no components — but it has `createScope()`, which serves the same structural purpose. A scope captures child effects, manages their cleanup, and can be nested inside other scopes or effects:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { createState, createEffect, createScope } from '@zeix/cause-effect'
|
|
114
|
+
|
|
115
|
+
const dispose = createScope(() => {
|
|
116
|
+
const count = createState(0)
|
|
117
|
+
|
|
118
|
+
createEffect(() => {
|
|
119
|
+
console.log(count.get())
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
return () => console.log('scope disposed')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Later: dispose everything created inside
|
|
126
|
+
dispose()
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Think of scopes as **components without rendering**. They are the building block for breaking the signal graph into smaller, manageable pieces — often driven by what needs to be looped or dynamically created. A UI framework built on this library would typically create a scope per component.
|
|
130
|
+
|
|
131
|
+
**Automatic vs. manual cleanup:**
|
|
132
|
+
|
|
133
|
+
- Inside a scope or parent effect, child effects are disposed automatically when the parent is disposed.
|
|
134
|
+
- Outside any owner, you must call the cleanup function returned by `createEffect()` yourself.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// Automatic: effect is disposed when the scope is disposed
|
|
138
|
+
const dispose = createScope(() => {
|
|
139
|
+
createEffect(() => console.log(count.get()))
|
|
140
|
+
})
|
|
141
|
+
dispose() // cleans up the effect
|
|
142
|
+
|
|
143
|
+
// Manual: no parent scope, you manage the lifetime
|
|
144
|
+
const cleanup = createEffect(() => console.log(count.get()))
|
|
145
|
+
cleanup() // you must call this yourself
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Explicit equality, not reference identity
|
|
149
|
+
|
|
150
|
+
By default, signals use `===` for equality. But unlike frameworks where this is buried in internals, you can override it per signal:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
const point = createState({ x: 0, y: 0 }, {
|
|
154
|
+
equals: (a, b) => a.x === b.x && a.y === b.y
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
point.set({ x: 0, y: 0 }) // no update — values are equal
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Beyond the Basics
|
|
161
|
+
|
|
162
|
+
The primitives above cover what most reactive libraries provide. The following signal types address patterns that frameworks handle with ad-hoc solutions or external libraries.
|
|
163
|
+
|
|
164
|
+
### Task: async derivations with cancellation
|
|
165
|
+
|
|
166
|
+
In React, async data fetching requires `useEffect` + cleanup + state management (or a library like React Query). In Angular, you'd use RxJS with `switchMap`. In Cause & Effect, `createTask()` is a signal that happens to be async:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { createState, createTask, createEffect, match } from '@zeix/cause-effect'
|
|
170
|
+
|
|
171
|
+
const userId = createState(1)
|
|
172
|
+
|
|
173
|
+
const user = createTask(async (prev, abort) => {
|
|
174
|
+
const res = await fetch(`/api/users/${userId.get()}`, { signal: abort })
|
|
175
|
+
return res.json()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
userId.set(2) // cancels the in-flight request, starts a new one
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The `abort` signal is managed automatically — when dependencies change, the previous computation is cancelled. No cleanup functions to write, no race conditions to handle.
|
|
182
|
+
|
|
183
|
+
Use `match()` inside effects to handle all states declaratively:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
createEffect(() => {
|
|
187
|
+
match([user], {
|
|
188
|
+
ok: ([data]) => console.log('User:', data),
|
|
189
|
+
nil: () => console.log('Loading...'),
|
|
190
|
+
err: (errors) => console.error(errors[0])
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Store: per-property reactivity
|
|
196
|
+
|
|
197
|
+
In React, updating one property of an object re-renders everything that reads the object. In Vue, `reactive()` gives you per-property tracking — `createStore()` works the same way:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import { createStore, createEffect } from '@zeix/cause-effect'
|
|
201
|
+
|
|
202
|
+
const user = createStore({ name: 'Alice', age: 30, email: 'alice@example.com' })
|
|
203
|
+
|
|
204
|
+
// This effect only re-runs when name changes
|
|
205
|
+
createEffect(() => {
|
|
206
|
+
console.log(user.name.get())
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
user.age.set(31) // does NOT trigger the effect above
|
|
210
|
+
user.name.set('Bob') // triggers it
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Each property becomes its own signal. Nested objects become nested stores. This is more granular than `createState({ ... })`, which would treat the whole object as a single value.
|
|
214
|
+
|
|
215
|
+
### List: reactive arrays with stable keys
|
|
216
|
+
|
|
217
|
+
Frameworks use `key` props (React), `:key` bindings (Vue), or `track` expressions (Angular) to maintain item identity during re-renders. In Cause & Effect, `createList()` bakes stable keys into the data structure itself:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
import { createList, createEffect } from '@zeix/cause-effect'
|
|
221
|
+
|
|
222
|
+
const todos = createList([
|
|
223
|
+
{ id: 't1', text: 'Learn signals', done: false },
|
|
224
|
+
{ id: 't2', text: 'Build app', done: false }
|
|
225
|
+
], { keyConfig: todo => todo.id })
|
|
226
|
+
|
|
227
|
+
// Get a stable reference to a specific item
|
|
228
|
+
const first = todos.byKey('t1')
|
|
229
|
+
|
|
230
|
+
todos.sort((a, b) => a.text.localeCompare(b.text))
|
|
231
|
+
// first still points to "Learn signals", regardless of position
|
|
232
|
+
|
|
233
|
+
// Update a single item without replacing the array
|
|
234
|
+
first?.set({ id: 't1', text: 'Learn signals', done: true })
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Each item is its own signal. Sorting reorders keys without destroying signals or their downstream dependencies. Adding and removing items is granular — unaffected items and their effects don't re-run.
|
|
238
|
+
|
|
239
|
+
### Collection: derived arrays with item-level memoization
|
|
240
|
+
|
|
241
|
+
Collections provide reactive transformations over arrays with automatic per-item memoization. They come in two forms: **derived collections** (transformations of Lists or other Collections) and **externally-driven collections** (fed by external sources like WebSockets or Server-Sent Events).
|
|
242
|
+
|
|
243
|
+
**Derived collections** are created via `.deriveCollection()` on a List or Collection:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
const display = todos.deriveCollection(todo => ({
|
|
247
|
+
label: todo.done ? `[x] ${todo.text}` : `[ ] ${todo.text}`
|
|
248
|
+
}))
|
|
249
|
+
|
|
250
|
+
// Async transformations with automatic cancellation
|
|
251
|
+
const enriched = todos.deriveCollection(async (todo, abort) => {
|
|
252
|
+
const res = await fetch(`/api/details/${todo.id}`, { signal: abort })
|
|
253
|
+
return { ...todo, details: await res.json() }
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// Chain collections for data pipelines
|
|
257
|
+
const pipeline = todos
|
|
258
|
+
.deriveCollection(todo => ({ ...todo, urgent: todo.priority > 8 }))
|
|
259
|
+
.deriveCollection(todo => todo.urgent ? `URGENT: ${todo.text}` : todo.text)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
When one item changes, only its derived signal recomputes. Structural changes (additions, removals) are tracked separately from value changes.
|
|
263
|
+
|
|
264
|
+
**Externally-driven collections** are created with `createCollection()` and a start callback for keyed data arriving from external sources:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { createCollection, createEffect } from '@zeix/cause-effect'
|
|
268
|
+
|
|
269
|
+
const messages = createCollection((applyChanges) => {
|
|
270
|
+
const ws = new WebSocket('/messages')
|
|
271
|
+
ws.onmessage = (e) => applyChanges({ changed: true, add: JSON.parse(e.data) })
|
|
272
|
+
return () => ws.close()
|
|
273
|
+
}, { keyConfig: msg => msg.id })
|
|
274
|
+
|
|
275
|
+
// Same Collection interface — .get(), .byKey(), .deriveCollection()
|
|
276
|
+
createEffect(() => {
|
|
277
|
+
console.log('Messages:', messages.get().length)
|
|
278
|
+
})
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The WebSocket connects when the first effect reads the collection and disconnects when no effects are watching. Incoming data is applied as granular add/change/remove operations, not wholesale array replacement.
|
|
282
|
+
|
|
283
|
+
### Sensor: lazy external input
|
|
284
|
+
|
|
285
|
+
Frameworks typically manage event listeners inside component lifecycle hooks (`useEffect`, `onMounted`, `ngOnInit`). In Cause & Effect, `createSensor()` encapsulates external input with automatic resource management:
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import { createSensor, createEffect } from '@zeix/cause-effect'
|
|
289
|
+
|
|
290
|
+
const windowSize = createSensor((set) => {
|
|
291
|
+
const update = () => set({ w: innerWidth, h: innerHeight })
|
|
292
|
+
update()
|
|
293
|
+
window.addEventListener('resize', update)
|
|
294
|
+
return () => window.removeEventListener('resize', update)
|
|
295
|
+
})
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The start callback runs lazily — only when an effect first reads the sensor. When no effects are watching, the cleanup runs automatically. When an effect reads it again, the start callback runs again. No manual setup/teardown.
|