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