@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.
Files changed (94) hide show
  1. package/.ai-context.md +169 -227
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +176 -116
  4. package/ARCHITECTURE.md +276 -0
  5. package/CHANGELOG.md +29 -0
  6. package/CLAUDE.md +201 -143
  7. package/GUIDE.md +298 -0
  8. package/README.md +246 -193
  9. package/REQUIREMENTS.md +100 -0
  10. package/bench/reactivity.bench.ts +577 -0
  11. package/context7.json +4 -0
  12. package/examples/events-sensor.ts +187 -0
  13. package/examples/selector-sensor.ts +173 -0
  14. package/index.dev.js +1390 -1008
  15. package/index.js +1 -1
  16. package/index.ts +60 -74
  17. package/package.json +5 -2
  18. package/skills/changelog-keeper/SKILL.md +59 -0
  19. package/skills/changelog-keeper/agents/openai.yaml +4 -0
  20. package/src/errors.ts +118 -74
  21. package/src/graph.ts +612 -0
  22. package/src/nodes/collection.ts +512 -0
  23. package/src/nodes/effect.ts +149 -0
  24. package/src/nodes/list.ts +589 -0
  25. package/src/nodes/memo.ts +148 -0
  26. package/src/nodes/sensor.ts +149 -0
  27. package/src/nodes/state.ts +135 -0
  28. package/src/nodes/store.ts +378 -0
  29. package/src/nodes/task.ts +174 -0
  30. package/src/signal.ts +112 -66
  31. package/src/util.ts +26 -57
  32. package/test/batch.test.ts +96 -62
  33. package/test/benchmark.test.ts +473 -487
  34. package/test/collection.test.ts +456 -707
  35. package/test/effect.test.ts +293 -696
  36. package/test/list.test.ts +335 -592
  37. package/test/memo.test.ts +574 -0
  38. package/test/regression.test.ts +156 -0
  39. package/test/scope.test.ts +191 -0
  40. package/test/sensor.test.ts +454 -0
  41. package/test/signal.test.ts +220 -213
  42. package/test/state.test.ts +217 -265
  43. package/test/store.test.ts +346 -446
  44. package/test/task.test.ts +529 -0
  45. package/test/untrack.test.ts +167 -0
  46. package/types/index.d.ts +13 -15
  47. package/types/src/errors.d.ts +73 -17
  48. package/types/src/graph.d.ts +218 -0
  49. package/types/src/nodes/collection.d.ts +69 -0
  50. package/types/src/nodes/effect.d.ts +48 -0
  51. package/types/src/nodes/list.d.ts +66 -0
  52. package/types/src/nodes/memo.d.ts +63 -0
  53. package/types/src/nodes/sensor.d.ts +81 -0
  54. package/types/src/nodes/state.d.ts +78 -0
  55. package/types/src/nodes/store.d.ts +51 -0
  56. package/types/src/nodes/task.d.ts +79 -0
  57. package/types/src/signal.d.ts +43 -29
  58. package/types/src/util.d.ts +9 -16
  59. package/archive/benchmark.ts +0 -683
  60. package/archive/collection.ts +0 -253
  61. package/archive/composite.ts +0 -85
  62. package/archive/computed.ts +0 -195
  63. package/archive/list.ts +0 -483
  64. package/archive/memo.ts +0 -139
  65. package/archive/state.ts +0 -90
  66. package/archive/store.ts +0 -298
  67. package/archive/task.ts +0 -189
  68. package/src/classes/collection.ts +0 -245
  69. package/src/classes/computed.ts +0 -349
  70. package/src/classes/list.ts +0 -343
  71. package/src/classes/ref.ts +0 -70
  72. package/src/classes/state.ts +0 -102
  73. package/src/classes/store.ts +0 -262
  74. package/src/diff.ts +0 -138
  75. package/src/effect.ts +0 -93
  76. package/src/match.ts +0 -45
  77. package/src/resolve.ts +0 -49
  78. package/src/system.ts +0 -257
  79. package/test/computed.test.ts +0 -1108
  80. package/test/diff.test.ts +0 -955
  81. package/test/match.test.ts +0 -388
  82. package/test/ref.test.ts +0 -353
  83. package/test/resolve.test.ts +0 -154
  84. package/types/src/classes/collection.d.ts +0 -45
  85. package/types/src/classes/computed.d.ts +0 -94
  86. package/types/src/classes/list.d.ts +0 -43
  87. package/types/src/classes/ref.d.ts +0 -35
  88. package/types/src/classes/state.d.ts +0 -49
  89. package/types/src/classes/store.d.ts +0 -52
  90. package/types/src/diff.d.ts +0 -28
  91. package/types/src/effect.d.ts +0 -15
  92. package/types/src/match.d.ts +0 -21
  93. package/types/src/resolve.d.ts +0 -29
  94. package/types/src/system.d.ts +0 -78
package/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## 0.18.1
4
+
5
+ ### Added
6
+
7
+ - **Memo `watched(invalidate)` option**: `createMemo(fn, { watched })` accepts a lazy lifecycle callback that receives an `invalidate` function. Calling `invalidate()` marks the memo dirty and triggers re-evaluation. The callback is invoked on first sink attachment and cleaned up when the last sink detaches. This enables patterns like DOM observation where a memo re-derives its value in response to external events (e.g., MutationObserver) without needing a separate Sensor.
8
+ - **Task `watched(invalidate)` option**: Same pattern as Memo. Calling `invalidate()` aborts any in-flight computation and triggers re-execution.
9
+ - **`CollectionChanges<T>` type**: New typed interface for collection mutations with `add?: T[]`, `change?: T[]`, `remove?: T[]` arrays. Replaces the untyped `DiffResult` records previously used by `CollectionCallback`.
10
+ - **`SensorOptions<T>` type**: Dedicated options type for `createSensor`, extending `SignalOptions<T>` with optional `value`.
11
+ - **`CollectionChanges` export** from public API (`index.ts`).
12
+ - **`SensorOptions` export** from public API (`index.ts`).
13
+
14
+ ### Changed
15
+
16
+ - **`createSensor` parameter renamed**: `start` → `watched` for consistency with Store/List lifecycle terminology.
17
+ - **`createSensor` options type**: `ComputedOptions<T>` → `SensorOptions<T>`. This decouples Sensor options from `ComputedOptions`, which now carries the `watched(invalidate)` field for Memo/Task.
18
+ - **`createCollection` parameter renamed**: `start` → `watched` for consistency.
19
+ - **`CollectionCallback` is now generic**: `CollectionCallback` → `CollectionCallback<T>`. The `applyChanges` parameter accepts `CollectionChanges<T>` instead of `DiffResult`.
20
+ - **`CollectionOptions.createItem` signature**: `(key: string, value: T) => Signal<T>` → `(value: T) => Signal<T>`. Key generation is now handled internally.
21
+ - **`KeyConfig<T>` return type relaxed**: Key functions may now return `string | undefined`. Returning `undefined` falls back to synthetic key generation.
22
+
23
+ ### Removed
24
+
25
+ - **`DiffResult` removed from public API**: No longer re-exported from `index.ts`. The type remains available from `src/nodes/list.ts` for internal use but is superseded by `CollectionChanges<T>` for collection mutations.
26
+
27
+ ## 0.18.0
28
+
29
+ Baseline release. Factory function API (`createState`, `createMemo`, `createTask`, `createEffect`, `createStore`, `createList`, `createCollection`, `createSensor`) with linked-list graph engine.
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
- - **Ref signals** are cells that reference external objects (DOM, Map, Set) requiring manual notification
18
- - **Computed signals** are formula cells that automatically recalculate when dependencies change
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 read-only derived tables with item-level memoization
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 Watcher System
27
- The core of reactivity lies in the watcher system (`src/system.ts`):
28
- - Each signal maintains a `Set<Watcher>` of subscribers
29
- - When a signal's `.get()` method is called during effect/computed execution, it automatically subscribes the current watcher
30
- - When a signal changes, it notifies all watchers, which then re-execute their callbacks
31
- - Batching prevents cascade updates during synchronous operations
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
- // Mutable signals extend this with mutation methods
42
- interface MutableSignal<T extends {}> extends Signal<T> {
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
- // Collection interface - implemented by various collection types
48
- interface Collection<T extends {}> {
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
- deriveCollection<R extends {}>(callback: CollectionCallback<R, T>): Collection<R>
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,80 +85,88 @@ The generic constraint `T extends {}` is crucial - it excludes `null` and `undef
57
85
 
58
86
  ### Collection Architecture
59
87
 
60
- Collections are an interface implemented by different reactive array types:
61
-
62
- **DerivedCollection**: Read-only transformations of Lists or other Collections
63
- - Item-level memoization with Computed signals
64
- - Async support with automatic cancellation
88
+ **Collections** (`createCollection`): Externally-driven collections with watched lifecycle
89
+ - Created via `createCollection(watched, options?)` — mirrors `createSensor(watched, options?)`
90
+ - The `watched` 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: `watched` 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
- - Lazy signal creation and automatic cleanup
82
- - Dynamic property addition/removal with proper reactivity
83
-
84
- **List signals** (`new List`): Arrays with stable keys and reactive items
85
- - Maintains stable keys that survive sorting and splicing
86
- - Built on `Composite` class for consistent signal management
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
114
+ - `deriveCollection()` creates derived Collections
88
115
 
89
116
  ### Computed Signal Memoization Strategy
90
117
 
91
118
  Computed signals implement smart memoization:
92
- - **Dependency Tracking**: Automatically tracks which signals are accessed during computation
93
- - **Stale Detection**: Only recalculates when dependencies actually change
94
- - **Async Support**: Handles Promise-based computations with automatic cancellation
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
95
122
  - **Error Handling**: Preserves error states and prevents cascade failures
96
123
  - **Reducer Capabilities**: Access to previous value enables state accumulation and transitions
97
124
 
98
125
  ## Resource Management with Watch Callbacks
99
126
 
100
- All signals support the `watched` and `unwatched` callbacks 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:
127
+ Sensor, Collection, Memo (with `watched` option), and Task (with `watched` option) use a **watched 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:
101
128
 
102
129
  ```typescript
103
- // Basic watch callbacks pattern
104
- const config = new State({ apiUrl: 'https://api.example.com' }, {
105
- watched: () => {
106
- console.log('Setting up API client...')
107
- const client = new ApiClient(config.get().apiUrl)
108
- },
109
- unwatched: () => {
110
- console.log('Cleaning up API client...')
111
- client.disconnect()
112
- }
113
- })
114
-
115
- // Resource is only created when effect runs
116
- const cleanup = createEffect(() => {
117
- console.log('API URL:', config.get().apiUrl) // Triggers watched callback
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)
118
135
  })
119
136
 
120
- cleanup() // Triggers unwatched callback
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 })
121
152
  ```
122
153
 
123
- **Practical Use Cases**:
124
- - Event listeners that activate only when data is watched
125
- - Network connections established on-demand
126
- - Expensive computations that pause when not needed
127
- - External subscriptions (WebSocket, Server-Sent Events)
128
- - Database connections tied to data access patterns
154
+ Store and List signals support an optional `watched` callback in their options:
155
+
156
+ ```typescript
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
+ }
163
+ })
164
+ ```
129
165
 
130
166
  **Watch Lifecycle**:
131
- 1. First effect accesses signal → `watched` callback executed
132
- 3. Last effect stops watching → `unwatched` callback executed
133
- 4. New effect accesses signal → `watched` callback executed again
167
+ 1. First effect accesses signal → watched callback executed
168
+ 2. Last effect stops watching → returned cleanup function executed
169
+ 3. New effect accesses signal → watched callback executed again
134
170
 
135
171
  This pattern enables **lazy resource allocation** - resources are only consumed when actually needed and automatically freed when no longer used.
136
172
 
@@ -138,126 +174,146 @@ This pattern enables **lazy resource allocation** - resources are only consumed
138
174
 
139
175
  ### When to Use Each Signal Type
140
176
 
141
- **State Signals (`State`)**:
177
+ **State (`createState`)**:
142
178
  - Primitive values (numbers, strings, booleans)
143
179
  - Objects that you replace entirely rather than mutating properties
144
180
  - Simple toggles and flags
145
- - Values with straightforward update patterns
146
181
 
147
182
  ```typescript
148
- const count = new State(0)
149
- const theme = new State<'light' | 'dark'>('light')
183
+ const count = createState(0)
184
+ const theme = createState<'light' | 'dark'>('light')
150
185
  ```
151
186
 
152
- **Ref Signals (`new Ref`)**:
153
- - External objects that change outside the reactive system
154
- - DOM elements, Map, Set, Date objects, third-party APIs
155
- - Requires manual `.notify()` when external object changes
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
156
192
 
157
193
  ```typescript
158
- const elementRef = new Ref(document.getElementById('status'))
159
- const cacheRef = new Ref(new Map())
160
- // When external change occurs: cacheRef.notify()
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 })
161
210
  ```
162
211
 
163
- **Store Signals (`createStore`)**:
212
+ **Store (`createStore`)**:
164
213
  - Objects where individual properties change independently
214
+ - Proxy-based: access properties directly as signals
165
215
 
166
216
  ```typescript
167
217
  const user = createStore({ name: 'Alice', email: 'alice@example.com' })
168
218
  user.name.set('Bob') // Only name subscribers react
169
219
  ```
170
220
 
171
- **List Signals (`new List`)**:
221
+ **List (`createList`)**:
222
+ - Arrays with stable keys and reactive items
223
+
172
224
  ```typescript
173
- const todoList = new List([
225
+ const todoList = createList([
174
226
  { id: 'task1', text: 'Learn signals' }
175
- ], todo => todo.id)
227
+ ], { keyConfig: todo => todo.id })
176
228
  const firstTodo = todoList.byKey('task1') // Access by stable key
177
229
  ```
178
230
 
179
- **Collection Signals (`new DerivedCollection`)**:
231
+ **Collection (`createCollection`)**:
232
+ - Externally-driven keyed collections (WebSocket streams, SSE, external data feeds)
233
+ - Mirrors `createSensor(watched, options?)` — watched callback pattern with lazy 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
+
180
248
  ```typescript
181
- const completedTodos = new DerivedCollection(todoList, todo =>
182
- todo.completed ? { ...todo, status: 'done' } : null
183
- )
184
- const todoDetails = new DerivedCollection(todoList, async (todo, abort) => {
185
- const response = await fetch(`/todos/${todo.id}`, { signal: abort })
186
- return { ...todo, details: await response.json() }
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() }
187
255
  })
188
256
 
189
257
  // Chain collections for data pipelines
190
- const urgentTodoSummaries = todoList
258
+ const processed = todoList
191
259
  .deriveCollection(todo => ({ ...todo, urgent: todo.priority > 8 }))
192
260
  .deriveCollection(todo => todo.urgent ? `URGENT: ${todo.text}` : todo.text)
193
-
194
- // Collections maintain stable references through List changes
195
- const firstTodoDetail = todoDetails.byKey('task1') // Computed signal
196
- todoList.sort() // Reorders list but collection signals remain stable
197
261
  ```
198
262
 
199
- **Computed Signals (`Memo` and `Task`)**:
200
- - Expensive calculations that should be memoized
201
- - Derived data that depends on multiple signals
202
- - Async operations that need automatic cancellation
203
- - Cross-cutting concerns that multiple components need
263
+ **Memo (`createMemo`)**:
264
+ - Synchronous derived computations with memoization
265
+ - Reducer pattern with previous value access
266
+ - Optional `watched(invalidate)` callback for lazy external invalidation (e.g., MutationObserver)
204
267
 
205
268
  ```typescript
206
- const expensiveCalc = new Memo(() => {
207
- return heavyComputation(data1.get(), data2.get()) // Memoized
208
- })
209
-
210
- const userData = new Task(async (prev, abort) => {
211
- const id = userId.get()
212
- if (!id) return prev // Keep previous data if no ID
213
- const response = await fetch(`/users/${id}`, { signal: abort })
214
- return response.json()
215
- })
269
+ const doubled = createMemo(() => count.get() * 2)
216
270
 
217
271
  // Reducer pattern for state machines
218
- const gameState = new Memo(prev => {
272
+ const gameState = createMemo(prev => {
219
273
  const action = playerAction.get()
220
274
  switch (prev) {
221
- case 'menu':
222
- return action === 'start' ? 'playing' : 'menu'
223
- case 'playing':
224
- return action === 'pause' ? 'paused' : action === 'gameover' ? 'ended' : 'playing'
225
- case 'paused':
226
- return action === 'resume' ? 'playing' : action === 'quit' ? 'menu' : 'paused'
227
- case 'ended':
228
- return action === 'restart' ? 'playing' : action === 'menu' ? 'menu' : 'ended'
229
- default:
230
- return 'menu'
275
+ case 'menu': return action === 'start' ? 'playing' : 'menu'
276
+ case 'playing': return action === 'pause' ? 'paused' : 'playing'
277
+ default: return prev
231
278
  }
232
- }, 'menu') // Initial state
279
+ }, { value: 'menu' })
280
+
281
+ // Accumulating values
282
+ const runningTotal = createMemo(prev => prev + currentValue.get(), { value: 0 })
283
+ ```
233
284
 
234
- // Accumulating values over time
235
- const runningTotal = new Memo(prev => {
236
- const newValue = currentValue.get()
237
- return previous + newValue
238
- }, 0) // Start with 0
285
+ **Task (`createTask`)**:
286
+ - Async computations with automatic cancellation
287
+ - Optional `watched(invalidate)` callback for lazy external invalidation
288
+
289
+ ```typescript
290
+ const userData = createTask(async (prev, abort) => {
291
+ const id = userId.get()
292
+ if (!id) return prev
293
+ const response = await fetch(`/users/${id}`, { signal: abort })
294
+ return response.json()
295
+ })
239
296
  ```
240
297
 
241
298
  ### Error Handling Strategies
242
299
 
243
300
  The library provides several layers of error handling:
244
301
 
245
- 1. **Input Validation**: Custom error classes for invalid operations
302
+ 1. **Input Validation**: `validateSignalValue()` and `validateCallback()` with custom error classes
246
303
  2. **Async Cancellation**: AbortSignal integration prevents stale async operations
247
- 3. **Error Propagation**: Computed signals preserve and propagate errors
248
- 4. **Helper Functions**: `resolve()` and `match()` for ergonomic error handling
304
+ 3. **Error Propagation**: Memo and Task preserve error states and throw on `.get()`
305
+ 4. **Match Helper**: `match()` for ergonomic signal value extraction inside effects
249
306
 
250
307
  ```typescript
251
- // Error handling with resolve() and match()
252
- const apiData = new Task(async (prev, abort) => {
308
+ const apiData = createTask(async (prev, abort) => {
253
309
  const response = await fetch('/api/data', { signal: abort })
254
310
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
255
311
  return response.json()
256
312
  })
257
313
 
258
314
  createEffect(() => {
259
- match(resolve({ apiData }), {
260
- ok: ({ apiData }) => updateUI(apiData),
315
+ match([apiData], {
316
+ ok: ([data]) => updateUI(data),
261
317
  nil: () => showLoading(),
262
318
  err: errors => showError(errors[0].message)
263
319
  })
@@ -266,9 +322,9 @@ createEffect(() => {
266
322
 
267
323
  ### Performance Optimization
268
324
 
269
- **Batching**: Use `batchSignalWrites()` for multiple updates
325
+ **Batching**: Use `batch()` for multiple updates
270
326
  ```typescript
271
- batchSignalWrites(() => {
327
+ batch(() => {
272
328
  user.name.set('Alice')
273
329
  user.email.set('alice@example.com')
274
330
  }) // Single effect trigger
@@ -277,17 +333,19 @@ batchSignalWrites(() => {
277
333
  **Granular Dependencies**: Structure computed signals to minimize dependencies
278
334
  ```typescript
279
335
  // Bad: depends on entire user object
280
- const display = new Memo(() => user.get().name + user.get().email)
281
- // Good: only depends on specific properties
282
- const display = new Memo(() => user.name.get() + user.email.get())
336
+ const display = createMemo(() => user.get().name + user.get().email)
337
+ // Good: only depends on specific properties
338
+ const display = createMemo(() => user.name.get() + user.email.get())
283
339
  ```
284
340
 
285
341
  ## Common Pitfalls
286
342
 
287
343
  1. **Infinite Loops**: Don't update signals within their own computed callbacks
288
- 2. **Memory Leaks**: Clean up effects when components unmount
344
+ 2. **Memory Leaks**: Clean up effects when components unmount
289
345
  3. **Over-reactivity**: Structure data to minimize unnecessary updates
290
346
  4. **Async Race Conditions**: Trust automatic cancellation with AbortSignal
347
+ 5. **Circular Dependencies**: The graph detects and throws `CircularDependencyError`
348
+ 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.
291
349
 
292
350
  ## Advanced Patterns
293
351
 
@@ -299,8 +357,8 @@ type Events = {
299
357
  }
300
358
 
301
359
  const eventBus = createStore<Events>({
302
- userLogin: UNSET,
303
- userLogout: UNSET
360
+ userLogin: undefined as unknown as Events['userLogin'],
361
+ userLogout: undefined as unknown as Events['userLogout'],
304
362
  })
305
363
 
306
364
  const emit = <K extends keyof Events>(event: K, data: Events[K]) => {
@@ -310,13 +368,13 @@ const emit = <K extends keyof Events>(event: K, data: Events[K]) => {
310
368
  const on = <K extends keyof Events>(event: K, callback: (data: Events[K]) => void) =>
311
369
  createEffect(() => {
312
370
  const data = eventBus[event].get()
313
- if (data !== UNSET) callback(data)
371
+ if (data != null) callback(data)
314
372
  })
315
373
  ```
316
374
 
317
375
  ### Data Processing Pipelines
318
376
  ```typescript
319
- const rawData = new List([{ id: 1, value: 10 }])
377
+ const rawData = createList([{ id: 1, value: 10 }], { keyConfig: item => String(item.id) })
320
378
  const processed = rawData
321
379
  .deriveCollection(item => ({ ...item, doubled: item.value * 2 }))
322
380
  .deriveCollection(item => ({ ...item, formatted: `$${item.doubled}` }))
@@ -324,9 +382,9 @@ const processed = rawData
324
382
 
325
383
  ### Stable List Keys
326
384
  ```typescript
327
- const playlist = new List([
385
+ const playlist = createList([
328
386
  { id: 'track1', title: 'Song A' }
329
- ], track => track.id)
387
+ ], { keyConfig: track => track.id })
330
388
 
331
389
  const firstTrack = playlist.byKey('track1') // Persists through sorting
332
390
  playlist.sort((a, b) => a.title.localeCompare(b.title))