@zeix/cause-effect 0.17.2 → 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 -226
- package/.cursorrules +41 -35
- package/.github/copilot-instructions.md +166 -116
- package/.zed/settings.json +3 -0
- package/ARCHITECTURE.md +274 -0
- package/CLAUDE.md +197 -202
- package/COLLECTION_REFACTORING.md +161 -0
- package/GUIDE.md +298 -0
- package/README.md +241 -220
- package/REQUIREMENTS.md +100 -0
- package/bench/reactivity.bench.ts +577 -0
- package/index.dev.js +1326 -1174
- package/index.js +1 -1
- package/index.ts +58 -85
- package/package.json +9 -6
- package/src/errors.ts +118 -70
- 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 -64
- package/src/util.ts +26 -57
- package/test/batch.test.ts +96 -69
- package/test/benchmark.test.ts +473 -485
- package/test/collection.test.ts +455 -955
- package/test/effect.test.ts +293 -696
- package/test/list.test.ts +332 -857
- 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 -271
- package/test/store.test.ts +346 -898
- package/test/task.test.ts +395 -0
- package/test/untrack.test.ts +167 -0
- package/test/util/dependency-graph.ts +2 -2
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +5 -7
- package/types/index.d.ts +13 -15
- package/types/src/errors.d.ts +73 -19
- 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 -28
- package/types/src/util.d.ts +9 -16
- package/archive/benchmark.ts +0 -688
- package/archive/collection.ts +0 -310
- package/archive/computed.ts +0 -198
- package/archive/list.ts +0 -544
- package/archive/memo.ts +0 -140
- package/archive/state.ts +0 -90
- package/archive/store.ts +0 -357
- package/archive/task.ts +0 -191
- package/src/classes/collection.ts +0 -298
- package/src/classes/composite.ts +0 -171
- package/src/classes/computed.ts +0 -392
- package/src/classes/list.ts +0 -310
- package/src/classes/ref.ts +0 -96
- package/src/classes/state.ts +0 -131
- package/src/classes/store.ts +0 -227
- package/src/diff.ts +0 -138
- package/src/effect.ts +0 -96
- package/src/match.ts +0 -45
- package/src/resolve.ts +0 -49
- package/src/system.ts +0 -275
- package/test/computed.test.ts +0 -1126
- package/test/diff.test.ts +0 -955
- package/test/match.test.ts +0 -388
- package/test/ref.test.ts +0 -381
- package/test/resolve.test.ts +0 -154
- package/types/src/classes/collection.d.ts +0 -47
- package/types/src/classes/composite.d.ts +0 -15
- package/types/src/classes/computed.d.ts +0 -114
- package/types/src/classes/list.d.ts +0 -41
- package/types/src/classes/ref.d.ts +0 -48
- package/types/src/classes/state.d.ts +0 -61
- package/types/src/classes/store.d.ts +0 -51
- 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 -81
package/CLAUDE.md
CHANGED
|
@@ -14,21 +14,34 @@ Cause & Effect is a reactive state management library that implements the signal
|
|
|
14
14
|
|
|
15
15
|
Think of signals as **observable cells** in a spreadsheet:
|
|
16
16
|
- **State signals** are input cells for primitive values and objects
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
17
|
+
- **Sensor signals** are read-only cells that track external input (mouse position, resize, mutable objects) and update lazily
|
|
18
|
+
- **Memo signals** are formula cells that automatically recalculate when dependencies change
|
|
19
|
+
- **Task signals** are async formula cells with abort semantics and pending state
|
|
19
20
|
- **Store signals** are structured tables where individual columns (properties) are reactive
|
|
20
21
|
- **List signals** are tables with stable row IDs that survive sorting and reordering
|
|
21
|
-
- **Collection signals** are
|
|
22
|
+
- **Collection signals** are externally-fed reactive tables with a watched lifecycle (WebSocket, SSE), or derived tables from Lists/Collections via `.deriveCollection()`
|
|
22
23
|
- **Effects** are event handlers that trigger side effects when cells change
|
|
23
24
|
|
|
24
25
|
## Architectural Deep Dive
|
|
25
26
|
|
|
26
|
-
### The
|
|
27
|
-
The core of reactivity lies in the
|
|
28
|
-
- Each
|
|
29
|
-
- When a signal's `.get()` method is called during effect/
|
|
30
|
-
- When a signal changes, it
|
|
31
|
-
-
|
|
27
|
+
### The Graph System
|
|
28
|
+
The core of reactivity lies in the linked graph system (`src/graph.ts`):
|
|
29
|
+
- Each source node maintains a linked list of `Edge` entries pointing to sink nodes
|
|
30
|
+
- When a signal's `.get()` method is called during effect/memo/task execution, it automatically links the source to the active sink via `link()`
|
|
31
|
+
- When a signal changes, it calls `propagate()` on all linked sinks, which flags them dirty
|
|
32
|
+
- `flush()` processes queued effects after propagation
|
|
33
|
+
- `batch()` defers flushing until the outermost batch completes
|
|
34
|
+
- `trimSources()` removes stale edges after recomputation
|
|
35
|
+
|
|
36
|
+
### Node Types in the Graph
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
StateNode<T> — source-only with equality + guard (State, Sensor)
|
|
40
|
+
MemoNode<T> — source + sink (Memo, Store, List, Collection)
|
|
41
|
+
TaskNode<T> — source + sink + async (Task)
|
|
42
|
+
EffectNode — sink + owner (Effect)
|
|
43
|
+
Scope — owner-only (createScope)
|
|
44
|
+
```
|
|
32
45
|
|
|
33
46
|
### Signal Hierarchy and Type System
|
|
34
47
|
|
|
@@ -38,18 +51,33 @@ interface Signal<T extends {}> {
|
|
|
38
51
|
get(): T
|
|
39
52
|
}
|
|
40
53
|
|
|
41
|
-
//
|
|
42
|
-
|
|
54
|
+
// State adds mutation methods
|
|
55
|
+
type State<T extends {}> = {
|
|
56
|
+
get(): T
|
|
43
57
|
set(value: T): void
|
|
44
58
|
update(fn: (current: T) => T): void
|
|
45
59
|
}
|
|
46
60
|
|
|
47
|
-
//
|
|
48
|
-
|
|
61
|
+
// Memo is read-only computed
|
|
62
|
+
type Memo<T extends {}> = {
|
|
63
|
+
get(): T
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Task adds async control
|
|
67
|
+
type Task<T extends {}> = {
|
|
68
|
+
get(): T
|
|
69
|
+
isPending(): boolean
|
|
70
|
+
abort(): void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Collection interface
|
|
74
|
+
type Collection<T extends {}> = {
|
|
49
75
|
get(): T[]
|
|
50
76
|
at(index: number): Signal<T> | undefined
|
|
51
77
|
byKey(key: string): Signal<T> | undefined
|
|
52
|
-
|
|
78
|
+
keys(): IterableIterator<string>
|
|
79
|
+
deriveCollection<R extends {}>(callback: (sourceValue: T) => R): Collection<R>
|
|
80
|
+
deriveCollection<R extends {}>(callback: (sourceValue: T, abort: AbortSignal) => Promise<R>): Collection<R>
|
|
53
81
|
}
|
|
54
82
|
```
|
|
55
83
|
|
|
@@ -57,141 +85,88 @@ The generic constraint `T extends {}` is crucial - it excludes `null` and `undef
|
|
|
57
85
|
|
|
58
86
|
### Collection Architecture
|
|
59
87
|
|
|
60
|
-
Collections
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
-
|
|
64
|
-
-
|
|
88
|
+
**Collections** (`createCollection`): Externally-driven collections with watched lifecycle
|
|
89
|
+
- Created via `createCollection(start, options?)` — mirrors `createSensor(start, options?)`
|
|
90
|
+
- The `start` callback receives an `applyChanges(diffResult)` function for granular add/change/remove operations
|
|
91
|
+
- `options.value` provides initial items (default `[]`), `options.keyConfig` configures key generation
|
|
92
|
+
- Lazy activation: `start` callback invoked on first effect access, cleanup when unwatched
|
|
93
|
+
|
|
94
|
+
**Derived Collections** (`deriveCollection`): Transformed from Lists or other Collections
|
|
95
|
+
- Created via `list.deriveCollection(callback)` or `collection.deriveCollection(callback)`
|
|
96
|
+
- Also available as `deriveCollection(source, callback)` (internal helper, exported for advanced use)
|
|
97
|
+
- Item-level memoization: sync callbacks use `createMemo`, async callbacks use `createTask`
|
|
98
|
+
- Structural changes tracked via an internal `MemoNode` that reads `source.keys()`
|
|
65
99
|
- Chainable for data pipelines
|
|
66
100
|
|
|
67
|
-
**ElementCollection**: DOM element collections with MutationObserver
|
|
68
|
-
- Uses Ref signals for elements that change externally
|
|
69
|
-
- Watches attributes and childList mutations
|
|
70
|
-
- Stable keys for persistent element identity
|
|
71
|
-
|
|
72
|
-
Key patterns:
|
|
73
|
-
- Collections return arrays of values via `.get()`
|
|
74
|
-
- Individual items accessed as signals via `.at()` and `.byKey()`
|
|
75
|
-
- All collections support `.deriveCollection()` for chaining
|
|
76
|
-
|
|
77
101
|
### Store and List Architecture
|
|
78
102
|
|
|
79
103
|
**Store signals** (`createStore`): Transform objects into reactive data structures
|
|
80
|
-
- Each property becomes its own signal via Proxy
|
|
81
|
-
-
|
|
82
|
-
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
-
|
|
104
|
+
- Each property becomes its own State signal (primitives) or nested Store (objects) via Proxy
|
|
105
|
+
- Uses an internal `MemoNode` for structural reactivity (add/remove properties)
|
|
106
|
+
- `diff()` computes granular changes when calling `set()`
|
|
107
|
+
- Dynamic property addition/removal with `add()`/`remove()`
|
|
108
|
+
|
|
109
|
+
**List signals** (`createList`): Arrays with stable keys and reactive items
|
|
110
|
+
- Each item becomes a `State` signal in a `Map<string, State<T>>`
|
|
111
|
+
- Uses an internal `MemoNode` for structural reactivity
|
|
112
|
+
- Configurable key generation: auto-increment, string prefix, or function
|
|
87
113
|
- Provides `byKey()`, `keyAt()`, `indexOfKey()` for key-based access
|
|
88
|
-
|
|
89
|
-
**Composite Architecture**: Shared foundation for Store and List
|
|
90
|
-
- `Map<string, Signal>` for property/item signals
|
|
91
|
-
- Hook system for granular add/change/remove notifications
|
|
92
|
-
- Lazy signal creation and automatic cleanup
|
|
114
|
+
- `deriveCollection()` creates derived Collections
|
|
93
115
|
|
|
94
116
|
### Computed Signal Memoization Strategy
|
|
95
117
|
|
|
96
118
|
Computed signals implement smart memoization:
|
|
97
|
-
- **Dependency Tracking**: Automatically tracks which signals are accessed during computation
|
|
98
|
-
- **Stale Detection**:
|
|
99
|
-
- **Async Support**:
|
|
119
|
+
- **Dependency Tracking**: Automatically tracks which signals are accessed during computation via `link()`
|
|
120
|
+
- **Stale Detection**: Flag-based dirty checking (CLEAN, CHECK, DIRTY) — only recalculates when dependencies actually change
|
|
121
|
+
- **Async Support**: `createTask` handles Promise-based computations with automatic AbortController cancellation
|
|
100
122
|
- **Error Handling**: Preserves error states and prevents cascade failures
|
|
101
123
|
- **Reducer Capabilities**: Access to previous value enables state accumulation and transitions
|
|
102
124
|
|
|
103
|
-
## Resource Management with Watch
|
|
125
|
+
## Resource Management with Watch Callbacks
|
|
104
126
|
|
|
105
|
-
|
|
127
|
+
Sensor and Collection signals use a **start callback** pattern for lazy resource management. Resources are allocated only when a signal is first accessed by an effect and automatically cleaned up when no effects are watching:
|
|
106
128
|
|
|
107
129
|
```typescript
|
|
108
|
-
//
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const client = new ApiClient(config.get().apiUrl)
|
|
114
|
-
|
|
115
|
-
return () => {
|
|
116
|
-
console.log('Cleaning up API client...')
|
|
117
|
-
client.disconnect()
|
|
118
|
-
}
|
|
130
|
+
// Sensor: track external input with state updates
|
|
131
|
+
const mousePos = createSensor<{ x: number; y: number }>((set) => {
|
|
132
|
+
const handler = (e: MouseEvent) => set({ x: e.clientX, y: e.clientY })
|
|
133
|
+
window.addEventListener('mousemove', handler)
|
|
134
|
+
return () => window.removeEventListener('mousemove', handler)
|
|
119
135
|
})
|
|
120
136
|
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return () => connection.close()
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
// Watch specific property
|
|
140
|
-
database.host.on('watch', () => {
|
|
141
|
-
console.log('Host property being watched')
|
|
142
|
-
return () => console.log('Host watching stopped')
|
|
143
|
-
})
|
|
137
|
+
// Sensor: observe a mutable external object (SKIP_EQUALITY)
|
|
138
|
+
const element = createSensor<HTMLElement>((set) => {
|
|
139
|
+
const node = document.getElementById('status')!
|
|
140
|
+
set(node)
|
|
141
|
+
const observer = new MutationObserver(() => set(node))
|
|
142
|
+
observer.observe(node, { attributes: true })
|
|
143
|
+
return () => observer.disconnect() // cleanup when unwatched
|
|
144
|
+
}, { value: node, equals: SKIP_EQUALITY })
|
|
145
|
+
|
|
146
|
+
// Collection: receive keyed data from external source
|
|
147
|
+
const feed = createCollection<{ id: string; text: string }>((applyChanges) => {
|
|
148
|
+
const es = new EventSource('/feed')
|
|
149
|
+
es.onmessage = (e) => applyChanges(JSON.parse(e.data))
|
|
150
|
+
return () => es.close()
|
|
151
|
+
}, { keyConfig: item => item.id })
|
|
144
152
|
```
|
|
145
153
|
|
|
146
|
-
|
|
154
|
+
Store and List signals support an optional `watched` callback in their options:
|
|
147
155
|
|
|
148
156
|
```typescript
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
// Item-level resource (individual items)
|
|
158
|
-
const firstItem = items.at(0)
|
|
159
|
-
firstItem.on('watch', () => {
|
|
160
|
-
console.log('First item being watched')
|
|
161
|
-
return () => console.log('First item watch stopped')
|
|
162
|
-
})
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
**Collection Watch Hooks**: Propagate to source List items
|
|
166
|
-
|
|
167
|
-
```typescript
|
|
168
|
-
const numbers = new List([1, 2, 3])
|
|
169
|
-
const doubled = numbers.deriveCollection(x => x * 2)
|
|
170
|
-
|
|
171
|
-
// Set up source item hook
|
|
172
|
-
numbers.at(0).on('watch', () => {
|
|
173
|
-
console.log('Source item accessed through collection')
|
|
174
|
-
return () => console.log('Source item no longer watched')
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
// Accessing collection item triggers source item hook
|
|
178
|
-
createEffect(() => {
|
|
179
|
-
const value = doubled.at(0).get() // Triggers source item hook
|
|
157
|
+
const user = createStore({ name: 'Alice', email: 'alice@example.com' }, {
|
|
158
|
+
watched: () => {
|
|
159
|
+
console.log('Store is now being watched')
|
|
160
|
+
const ws = new WebSocket('/updates')
|
|
161
|
+
return () => ws.close() // cleanup returned as Cleanup
|
|
162
|
+
}
|
|
180
163
|
})
|
|
181
164
|
```
|
|
182
165
|
|
|
183
|
-
**
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
- External subscriptions (WebSocket, Server-Sent Events)
|
|
188
|
-
- Database connections tied to data access patterns
|
|
189
|
-
|
|
190
|
-
**Hook Lifecycle**:
|
|
191
|
-
1. First effect accesses signal → `watch` hook callback executed
|
|
192
|
-
2. Hook callback can return cleanup function
|
|
193
|
-
3. Last effect stops watching → cleanup function called
|
|
194
|
-
4. New effect accesses signal → hook callback executed again
|
|
166
|
+
**Watch Lifecycle**:
|
|
167
|
+
1. First effect accesses signal → start/watched callback executed
|
|
168
|
+
2. Last effect stops watching → returned cleanup function executed
|
|
169
|
+
3. New effect accesses signal → start/watched callback executed again
|
|
195
170
|
|
|
196
171
|
This pattern enables **lazy resource allocation** - resources are only consumed when actually needed and automatically freed when no longer used.
|
|
197
172
|
|
|
@@ -199,126 +174,144 @@ This pattern enables **lazy resource allocation** - resources are only consumed
|
|
|
199
174
|
|
|
200
175
|
### When to Use Each Signal Type
|
|
201
176
|
|
|
202
|
-
**State
|
|
177
|
+
**State (`createState`)**:
|
|
203
178
|
- Primitive values (numbers, strings, booleans)
|
|
204
179
|
- Objects that you replace entirely rather than mutating properties
|
|
205
180
|
- Simple toggles and flags
|
|
206
|
-
- Values with straightforward update patterns
|
|
207
181
|
|
|
208
182
|
```typescript
|
|
209
|
-
const count =
|
|
210
|
-
const theme =
|
|
183
|
+
const count = createState(0)
|
|
184
|
+
const theme = createState<'light' | 'dark'>('light')
|
|
211
185
|
```
|
|
212
186
|
|
|
213
|
-
**
|
|
214
|
-
- External
|
|
215
|
-
-
|
|
216
|
-
-
|
|
187
|
+
**Sensor (`createSensor`)**:
|
|
188
|
+
- External input streams (mouse position, window size, media queries)
|
|
189
|
+
- External mutable objects observed via MutationObserver, IntersectionObserver, etc.
|
|
190
|
+
- Returns a read-only signal that activates lazily and tears down when unwatched
|
|
191
|
+
- Starts undefined until first `set()` unless `options.value` is provided
|
|
217
192
|
|
|
218
193
|
```typescript
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
194
|
+
// Tracking external values (default equality)
|
|
195
|
+
const windowSize = createSensor<{ w: number; h: number }>((set) => {
|
|
196
|
+
const update = () => set({ w: innerWidth, h: innerHeight })
|
|
197
|
+
update()
|
|
198
|
+
window.addEventListener('resize', update)
|
|
199
|
+
return () => window.removeEventListener('resize', update)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// Observing a mutable object (SKIP_EQUALITY)
|
|
203
|
+
const el = createSensor<HTMLElement>((set) => {
|
|
204
|
+
const node = document.getElementById('box')!
|
|
205
|
+
set(node)
|
|
206
|
+
const obs = new MutationObserver(() => set(node))
|
|
207
|
+
obs.observe(node, { attributes: true })
|
|
208
|
+
return () => obs.disconnect()
|
|
209
|
+
}, { value: node, equals: SKIP_EQUALITY })
|
|
222
210
|
```
|
|
223
211
|
|
|
224
|
-
**Store
|
|
212
|
+
**Store (`createStore`)**:
|
|
225
213
|
- Objects where individual properties change independently
|
|
214
|
+
- Proxy-based: access properties directly as signals
|
|
226
215
|
|
|
227
216
|
```typescript
|
|
228
217
|
const user = createStore({ name: 'Alice', email: 'alice@example.com' })
|
|
229
218
|
user.name.set('Bob') // Only name subscribers react
|
|
230
219
|
```
|
|
231
220
|
|
|
232
|
-
**List
|
|
221
|
+
**List (`createList`)**:
|
|
222
|
+
- Arrays with stable keys and reactive items
|
|
223
|
+
|
|
233
224
|
```typescript
|
|
234
|
-
const todoList =
|
|
225
|
+
const todoList = createList([
|
|
235
226
|
{ id: 'task1', text: 'Learn signals' }
|
|
236
|
-
], todo => todo.id)
|
|
227
|
+
], { keyConfig: todo => todo.id })
|
|
237
228
|
const firstTodo = todoList.byKey('task1') // Access by stable key
|
|
238
229
|
```
|
|
239
230
|
|
|
240
|
-
**Collection
|
|
231
|
+
**Collection (`createCollection`)**:
|
|
232
|
+
- Externally-driven keyed collections (WebSocket streams, SSE, external data feeds)
|
|
233
|
+
- Mirrors `createSensor(start, options?)` — start callback pattern with watched lifecycle
|
|
234
|
+
- Same `Collection` interface — `.get()`, `.byKey()`, `.keys()`, `.deriveCollection()`
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
const feed = createCollection<{ id: string; text: string }>((applyChanges) => {
|
|
238
|
+
const ws = new WebSocket('/feed')
|
|
239
|
+
ws.onmessage = (e) => applyChanges(JSON.parse(e.data))
|
|
240
|
+
return () => ws.close()
|
|
241
|
+
}, { keyConfig: item => item.id })
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Derived Collection** (`.deriveCollection()`):
|
|
245
|
+
- Read-only derived transformations of Lists or other Collections
|
|
246
|
+
- Created via `.deriveCollection()` method on List or Collection
|
|
247
|
+
|
|
241
248
|
```typescript
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
const response = await fetch(`/
|
|
247
|
-
return { ...
|
|
249
|
+
const doubled = numbers.deriveCollection((value: number) => value * 2)
|
|
250
|
+
|
|
251
|
+
// Async transformation
|
|
252
|
+
const enriched = users.deriveCollection(async (user, abort) => {
|
|
253
|
+
const response = await fetch(`/api/${user.id}`, { signal: abort })
|
|
254
|
+
return { ...user, details: await response.json() }
|
|
248
255
|
})
|
|
249
256
|
|
|
250
257
|
// Chain collections for data pipelines
|
|
251
|
-
const
|
|
258
|
+
const processed = todoList
|
|
252
259
|
.deriveCollection(todo => ({ ...todo, urgent: todo.priority > 8 }))
|
|
253
260
|
.deriveCollection(todo => todo.urgent ? `URGENT: ${todo.text}` : todo.text)
|
|
254
|
-
|
|
255
|
-
// Collections maintain stable references through List changes
|
|
256
|
-
const firstTodoDetail = todoDetails.byKey('task1') // Computed signal
|
|
257
|
-
todoList.sort() // Reorders list but collection signals remain stable
|
|
258
261
|
```
|
|
259
262
|
|
|
260
|
-
**
|
|
261
|
-
-
|
|
262
|
-
-
|
|
263
|
-
- Async operations that need automatic cancellation
|
|
264
|
-
- Cross-cutting concerns that multiple components need
|
|
263
|
+
**Memo (`createMemo`)**:
|
|
264
|
+
- Synchronous derived computations with memoization
|
|
265
|
+
- Reducer pattern with previous value access
|
|
265
266
|
|
|
266
267
|
```typescript
|
|
267
|
-
const
|
|
268
|
-
return heavyComputation(data1.get(), data2.get()) // Memoized
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
const userData = new Task(async (prev, abort) => {
|
|
272
|
-
const id = userId.get()
|
|
273
|
-
if (!id) return prev // Keep previous data if no ID
|
|
274
|
-
const response = await fetch(`/users/${id}`, { signal: abort })
|
|
275
|
-
return response.json()
|
|
276
|
-
})
|
|
268
|
+
const doubled = createMemo(() => count.get() * 2)
|
|
277
269
|
|
|
278
270
|
// Reducer pattern for state machines
|
|
279
|
-
const gameState =
|
|
271
|
+
const gameState = createMemo(prev => {
|
|
280
272
|
const action = playerAction.get()
|
|
281
273
|
switch (prev) {
|
|
282
|
-
case 'menu':
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
return action === 'pause' ? 'paused' : action === 'gameover' ? 'ended' : 'playing'
|
|
286
|
-
case 'paused':
|
|
287
|
-
return action === 'resume' ? 'playing' : action === 'quit' ? 'menu' : 'paused'
|
|
288
|
-
case 'ended':
|
|
289
|
-
return action === 'restart' ? 'playing' : action === 'menu' ? 'menu' : 'ended'
|
|
290
|
-
default:
|
|
291
|
-
return 'menu'
|
|
274
|
+
case 'menu': return action === 'start' ? 'playing' : 'menu'
|
|
275
|
+
case 'playing': return action === 'pause' ? 'paused' : 'playing'
|
|
276
|
+
default: return prev
|
|
292
277
|
}
|
|
293
|
-
}, 'menu')
|
|
278
|
+
}, { value: 'menu' })
|
|
294
279
|
|
|
295
|
-
// Accumulating values
|
|
296
|
-
const runningTotal =
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
280
|
+
// Accumulating values
|
|
281
|
+
const runningTotal = createMemo(prev => prev + currentValue.get(), { value: 0 })
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
**Task (`createTask`)**:
|
|
285
|
+
- Async computations with automatic cancellation
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
const userData = createTask(async (prev, abort) => {
|
|
289
|
+
const id = userId.get()
|
|
290
|
+
if (!id) return prev
|
|
291
|
+
const response = await fetch(`/users/${id}`, { signal: abort })
|
|
292
|
+
return response.json()
|
|
293
|
+
})
|
|
300
294
|
```
|
|
301
295
|
|
|
302
296
|
### Error Handling Strategies
|
|
303
297
|
|
|
304
298
|
The library provides several layers of error handling:
|
|
305
299
|
|
|
306
|
-
1. **Input Validation**:
|
|
300
|
+
1. **Input Validation**: `validateSignalValue()` and `validateCallback()` with custom error classes
|
|
307
301
|
2. **Async Cancellation**: AbortSignal integration prevents stale async operations
|
|
308
|
-
3. **Error Propagation**:
|
|
309
|
-
4. **Helper
|
|
302
|
+
3. **Error Propagation**: Memo and Task preserve error states and throw on `.get()`
|
|
303
|
+
4. **Match Helper**: `match()` for ergonomic signal value extraction inside effects
|
|
310
304
|
|
|
311
305
|
```typescript
|
|
312
|
-
|
|
313
|
-
const apiData = new Task(async (prev, abort) => {
|
|
306
|
+
const apiData = createTask(async (prev, abort) => {
|
|
314
307
|
const response = await fetch('/api/data', { signal: abort })
|
|
315
308
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
316
309
|
return response.json()
|
|
317
310
|
})
|
|
318
311
|
|
|
319
312
|
createEffect(() => {
|
|
320
|
-
match(
|
|
321
|
-
ok: (
|
|
313
|
+
match([apiData], {
|
|
314
|
+
ok: ([data]) => updateUI(data),
|
|
322
315
|
nil: () => showLoading(),
|
|
323
316
|
err: errors => showError(errors[0].message)
|
|
324
317
|
})
|
|
@@ -327,9 +320,9 @@ createEffect(() => {
|
|
|
327
320
|
|
|
328
321
|
### Performance Optimization
|
|
329
322
|
|
|
330
|
-
**Batching**: Use `
|
|
323
|
+
**Batching**: Use `batch()` for multiple updates
|
|
331
324
|
```typescript
|
|
332
|
-
|
|
325
|
+
batch(() => {
|
|
333
326
|
user.name.set('Alice')
|
|
334
327
|
user.email.set('alice@example.com')
|
|
335
328
|
}) // Single effect trigger
|
|
@@ -338,17 +331,19 @@ batchSignalWrites(() => {
|
|
|
338
331
|
**Granular Dependencies**: Structure computed signals to minimize dependencies
|
|
339
332
|
```typescript
|
|
340
333
|
// Bad: depends on entire user object
|
|
341
|
-
const display =
|
|
342
|
-
// Good: only depends on specific properties
|
|
343
|
-
const display =
|
|
334
|
+
const display = createMemo(() => user.get().name + user.get().email)
|
|
335
|
+
// Good: only depends on specific properties
|
|
336
|
+
const display = createMemo(() => user.name.get() + user.email.get())
|
|
344
337
|
```
|
|
345
338
|
|
|
346
339
|
## Common Pitfalls
|
|
347
340
|
|
|
348
341
|
1. **Infinite Loops**: Don't update signals within their own computed callbacks
|
|
349
|
-
2. **Memory Leaks**: Clean up effects when components unmount
|
|
342
|
+
2. **Memory Leaks**: Clean up effects when components unmount
|
|
350
343
|
3. **Over-reactivity**: Structure data to minimize unnecessary updates
|
|
351
344
|
4. **Async Race Conditions**: Trust automatic cancellation with AbortSignal
|
|
345
|
+
5. **Circular Dependencies**: The graph detects and throws `CircularDependencyError`
|
|
346
|
+
6. **Untracked `byKey()`/`at()` access**: On Store, List, and Collection, `byKey()`, `at()`, `keyAt()`, and `indexOfKey()` do **not** create graph edges. They are direct lookups that bypass structural tracking. An effect using only `collection.byKey('x')?.get()` will react to value changes of key `'x'`, but will **not** re-run if key `'x'` is added or removed. Use `get()`, `keys()`, or `length` to track structural changes.
|
|
352
347
|
|
|
353
348
|
## Advanced Patterns
|
|
354
349
|
|
|
@@ -360,8 +355,8 @@ type Events = {
|
|
|
360
355
|
}
|
|
361
356
|
|
|
362
357
|
const eventBus = createStore<Events>({
|
|
363
|
-
userLogin:
|
|
364
|
-
userLogout:
|
|
358
|
+
userLogin: undefined as unknown as Events['userLogin'],
|
|
359
|
+
userLogout: undefined as unknown as Events['userLogout'],
|
|
365
360
|
})
|
|
366
361
|
|
|
367
362
|
const emit = <K extends keyof Events>(event: K, data: Events[K]) => {
|
|
@@ -371,13 +366,13 @@ const emit = <K extends keyof Events>(event: K, data: Events[K]) => {
|
|
|
371
366
|
const on = <K extends keyof Events>(event: K, callback: (data: Events[K]) => void) =>
|
|
372
367
|
createEffect(() => {
|
|
373
368
|
const data = eventBus[event].get()
|
|
374
|
-
if (data
|
|
369
|
+
if (data != null) callback(data)
|
|
375
370
|
})
|
|
376
371
|
```
|
|
377
372
|
|
|
378
373
|
### Data Processing Pipelines
|
|
379
374
|
```typescript
|
|
380
|
-
const rawData =
|
|
375
|
+
const rawData = createList([{ id: 1, value: 10 }], { keyConfig: item => String(item.id) })
|
|
381
376
|
const processed = rawData
|
|
382
377
|
.deriveCollection(item => ({ ...item, doubled: item.value * 2 }))
|
|
383
378
|
.deriveCollection(item => ({ ...item, formatted: `$${item.doubled}` }))
|
|
@@ -385,9 +380,9 @@ const processed = rawData
|
|
|
385
380
|
|
|
386
381
|
### Stable List Keys
|
|
387
382
|
```typescript
|
|
388
|
-
const playlist =
|
|
383
|
+
const playlist = createList([
|
|
389
384
|
{ id: 'track1', title: 'Song A' }
|
|
390
|
-
], track => track.id)
|
|
385
|
+
], { keyConfig: track => track.id })
|
|
391
386
|
|
|
392
387
|
const firstTrack = playlist.byKey('track1') // Persists through sorting
|
|
393
388
|
playlist.sort((a, b) => a.title.localeCompare(b.title))
|