@zeix/cause-effect 0.17.3 → 0.18.0
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 +163 -232
- package/.cursorrules +41 -35
- package/.github/copilot-instructions.md +166 -116
- package/ARCHITECTURE.md +274 -0
- package/CLAUDE.md +199 -143
- package/COLLECTION_REFACTORING.md +161 -0
- package/GUIDE.md +298 -0
- package/README.md +232 -197
- package/REQUIREMENTS.md +100 -0
- package/bench/reactivity.bench.ts +577 -0
- package/index.dev.js +1325 -997
- package/index.js +1 -1
- package/index.ts +58 -74
- package/package.json +4 -1
- package/src/errors.ts +118 -74
- package/src/graph.ts +601 -0
- package/src/nodes/collection.ts +474 -0
- package/src/nodes/effect.ts +149 -0
- package/src/nodes/list.ts +588 -0
- package/src/nodes/memo.ts +120 -0
- package/src/nodes/sensor.ts +139 -0
- package/src/nodes/state.ts +135 -0
- package/src/nodes/store.ts +383 -0
- package/src/nodes/task.ts +146 -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 +466 -706
- package/test/effect.test.ts +293 -696
- package/test/list.test.ts +335 -592
- package/test/memo.test.ts +380 -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 +395 -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 +208 -0
- package/types/src/nodes/collection.d.ts +64 -0
- package/types/src/nodes/effect.d.ts +48 -0
- package/types/src/nodes/list.d.ts +65 -0
- package/types/src/nodes/memo.d.ts +57 -0
- package/types/src/nodes/sensor.d.ts +75 -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 +73 -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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Collection Refactoring Plan
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Unify `createCollection()` and `createSourceCollection()` into a single `createCollection()` primitive whose primary form mirrors `createSensor()`: an externally-driven signal with a watched lifecycle. The derived-from-List/Collection form becomes an internal helper used by `.deriveCollection()`.
|
|
6
|
+
|
|
7
|
+
## Motivation
|
|
8
|
+
|
|
9
|
+
- **Sensor ↔ Collection parallel**: Both are externally-driven, lazily activated, and auto-cleaned. Making their signatures parallel sharpens this mental model.
|
|
10
|
+
- **One primitive, one name**: Users learn `createCollection(start, options)` the same way they learn `createSensor(start, options)`.
|
|
11
|
+
- **Derived collections are a method, not a standalone call**: `list.deriveCollection(fn)` and `collection.deriveCollection(fn)` already exist and are the natural way to create derived collections.
|
|
12
|
+
|
|
13
|
+
## New API Surface
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
// Primary form — externally driven (replaces createSourceCollection)
|
|
17
|
+
function createCollection<T extends {}>(
|
|
18
|
+
start: CollectionCallback<T>,
|
|
19
|
+
options?: CollectionOptions<T>,
|
|
20
|
+
): Collection<T>
|
|
21
|
+
|
|
22
|
+
// CollectionCallback mirrors SensorCallback but receives applyChanges
|
|
23
|
+
type CollectionCallback<T extends {}> = (
|
|
24
|
+
applyChanges: (changes: DiffResult) => void,
|
|
25
|
+
) => Cleanup
|
|
26
|
+
|
|
27
|
+
// CollectionOptions — initial value hidden in options (like Memo, Task, Sensor)
|
|
28
|
+
type CollectionOptions<T extends {}> = {
|
|
29
|
+
value?: T[] // initial items (default: [])
|
|
30
|
+
keyConfig?: KeyConfig<T> // key generation strategy
|
|
31
|
+
createItem?: (key: string, value: T) => Signal<T> // custom item factory
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Derive method — unchanged on List and Collection
|
|
35
|
+
collection.deriveCollection(callback)
|
|
36
|
+
list.deriveCollection(callback)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Refactoring Steps
|
|
40
|
+
|
|
41
|
+
Order matters: the existing `createCollection` and `CollectionCallback` names must be freed up before they can be reused for the new concept. The refactoring proceeds in two phases.
|
|
42
|
+
|
|
43
|
+
### Phase 1 — Rename existing symbols (free the names)
|
|
44
|
+
|
|
45
|
+
#### 1.1. Rename `createCollection` → `deriveCollection`
|
|
46
|
+
|
|
47
|
+
In `src/nodes/collection.ts`:
|
|
48
|
+
|
|
49
|
+
- Rename the function `createCollection(source, callback)` → `deriveCollection(source, callback)`.
|
|
50
|
+
- Update both overload signatures and the implementation signature.
|
|
51
|
+
- Update the internal `deriveCollection()` call inside the `Collection.deriveCollection` method body (both in the derived-collection object and the source-collection object).
|
|
52
|
+
|
|
53
|
+
#### 1.2. Rename `CollectionCallback<T, U>` → `DeriveCollectionCallback<T, U>`
|
|
54
|
+
|
|
55
|
+
- Rename the type alias in `src/nodes/collection.ts`.
|
|
56
|
+
- Update all references: the `deriveCollection` parameter types, and the `Collection.deriveCollection` method parameter type annotations.
|
|
57
|
+
|
|
58
|
+
#### 1.3. Update `list.ts`
|
|
59
|
+
|
|
60
|
+
- Change the import from `createCollection` to `deriveCollection`.
|
|
61
|
+
- Update `List.deriveCollection()` body to call `deriveCollection(list, cb)`.
|
|
62
|
+
|
|
63
|
+
#### 1.4. Update exports in `index.ts`
|
|
64
|
+
|
|
65
|
+
- Replace `createCollection` → `deriveCollection` in the export list.
|
|
66
|
+
- Replace `CollectionCallback` → `DeriveCollectionCallback`.
|
|
67
|
+
- Keep or drop `CollectionSource` from public exports (internal detail of `deriveCollection`).
|
|
68
|
+
|
|
69
|
+
#### 1.5. Update tests
|
|
70
|
+
|
|
71
|
+
- In `test/collection.test.ts` (or `test/collection.next.test.ts`): replace all direct `createCollection(source, cb)` calls with either `deriveCollection(source, cb)` or the equivalent `.deriveCollection(cb)` method.
|
|
72
|
+
- Update imports accordingly.
|
|
73
|
+
|
|
74
|
+
#### 1.6. Verify
|
|
75
|
+
|
|
76
|
+
- `bun run check` and `bun test` pass.
|
|
77
|
+
- Commit: "Rename createCollection → deriveCollection, CollectionCallback → DeriveCollectionCallback"
|
|
78
|
+
|
|
79
|
+
### Phase 2 — Reshape `createSourceCollection` → `createCollection`
|
|
80
|
+
|
|
81
|
+
#### 2.1. Rename and reshape function
|
|
82
|
+
|
|
83
|
+
In `src/nodes/collection.ts`:
|
|
84
|
+
|
|
85
|
+
- Rename `createSourceCollection` → `createCollection`.
|
|
86
|
+
- Move `initialValue` from first positional arg into `options.value` (default `[]`).
|
|
87
|
+
- New signature: `createCollection<T>(start: CollectionCallback<T>, options?: CollectionOptions<T>)`.
|
|
88
|
+
|
|
89
|
+
#### 2.2. Rename types
|
|
90
|
+
|
|
91
|
+
- `SourceCollectionCallback` → `CollectionCallback<T>` (generic over `T` for type coherence with `CollectionOptions<T>`, even though the callback itself doesn't use `T` directly).
|
|
92
|
+
- `SourceCollectionOptions<T>` → `CollectionOptions<T>`, adding the `value?: T[]` field.
|
|
93
|
+
|
|
94
|
+
#### 2.3. Update exports in `index.ts`
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// Remove
|
|
98
|
+
export { createSourceCollection, SourceCollectionCallback, SourceCollectionOptions, CollectionSource }
|
|
99
|
+
|
|
100
|
+
// Add / rename
|
|
101
|
+
export { createCollection, CollectionCallback, CollectionOptions }
|
|
102
|
+
|
|
103
|
+
// Keep
|
|
104
|
+
export { Collection, DiffResult, isCollection }
|
|
105
|
+
|
|
106
|
+
// Optional (if deriveCollection is exported)
|
|
107
|
+
export { deriveCollection, DeriveCollectionCallback }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
#### 2.4. Update tests
|
|
111
|
+
|
|
112
|
+
- `test/source-collection.test.ts` → rename to `test/collection.next.test.ts` (or merge into existing collection test file).
|
|
113
|
+
- Update all `createSourceCollection(initialValue, start, options)` calls to `createCollection(start, { value: initialValue, ...options })`.
|
|
114
|
+
- Update type imports: `CollectionCallback` instead of `SourceCollectionCallback`, etc.
|
|
115
|
+
|
|
116
|
+
#### 2.5. Update CLAUDE.md and docs
|
|
117
|
+
|
|
118
|
+
- Update the Collection section to present `createCollection(start, options)` as the primary form.
|
|
119
|
+
- Show `.deriveCollection()` as the way to transform Lists/Collections.
|
|
120
|
+
- Emphasize Sensor ↔ Collection parallel in the mental model section.
|
|
121
|
+
|
|
122
|
+
#### 2.6. Verify
|
|
123
|
+
|
|
124
|
+
- `bun run check` and `bun test` pass.
|
|
125
|
+
- Commit: "Reshape createSourceCollection → createCollection(start, options)"
|
|
126
|
+
|
|
127
|
+
## Type Summary
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
Before After
|
|
131
|
+
───────────────────────────────── ─────────────────────────────────
|
|
132
|
+
createSourceCollection(init, start, opts) → createCollection(start, opts)
|
|
133
|
+
SourceCollectionCallback → CollectionCallback<T>
|
|
134
|
+
SourceCollectionOptions<T> → CollectionOptions<T>
|
|
135
|
+
|
|
136
|
+
createCollection(source, callback) → deriveCollection(source, callback) [internal]
|
|
137
|
+
CollectionCallback<T, U> → DeriveCollectionCallback<T, U> [internal or optional export]
|
|
138
|
+
CollectionSource<T> → CollectionSource<T> [internal]
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Migration Checklist
|
|
142
|
+
|
|
143
|
+
### Phase 1 — Free the names
|
|
144
|
+
- [ ] Rename `createCollection(source, cb)` → `deriveCollection(source, cb)`
|
|
145
|
+
- [ ] Rename type `CollectionCallback<T, U>` → `DeriveCollectionCallback<T, U>`
|
|
146
|
+
- [ ] Update `List.deriveCollection()` and `Collection.deriveCollection()` to call `deriveCollection()`
|
|
147
|
+
- [ ] Update `index.ts` exports (phase 1)
|
|
148
|
+
- [ ] Update tests (phase 1)
|
|
149
|
+
- [ ] Verify: `bun run check` and `bun test` pass
|
|
150
|
+
- [ ] Commit phase 1
|
|
151
|
+
|
|
152
|
+
### Phase 2 — Reclaim the names
|
|
153
|
+
- [ ] Rename `createSourceCollection` → `createCollection(start, options)` with `options.value`
|
|
154
|
+
- [ ] Rename type `SourceCollectionCallback` → `CollectionCallback<T>`
|
|
155
|
+
- [ ] Rename type `SourceCollectionOptions` → `CollectionOptions` (add `value?: T[]`)
|
|
156
|
+
- [ ] Drop `CollectionSource` from public exports
|
|
157
|
+
- [ ] Update `index.ts` exports (phase 2)
|
|
158
|
+
- [ ] Update tests (phase 2)
|
|
159
|
+
- [ ] Update CLAUDE.md Collection sections
|
|
160
|
+
- [ ] Verify: `bun run check` and `bun test` pass
|
|
161
|
+
- [ ] Commit phase 2
|
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.
|