@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.
Files changed (94) hide show
  1. package/.ai-context.md +163 -226
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +166 -116
  4. package/.zed/settings.json +3 -0
  5. package/ARCHITECTURE.md +274 -0
  6. package/CLAUDE.md +197 -202
  7. package/COLLECTION_REFACTORING.md +161 -0
  8. package/GUIDE.md +298 -0
  9. package/README.md +241 -220
  10. package/REQUIREMENTS.md +100 -0
  11. package/bench/reactivity.bench.ts +577 -0
  12. package/index.dev.js +1326 -1174
  13. package/index.js +1 -1
  14. package/index.ts +58 -85
  15. package/package.json +9 -6
  16. package/src/errors.ts +118 -70
  17. package/src/graph.ts +601 -0
  18. package/src/nodes/collection.ts +474 -0
  19. package/src/nodes/effect.ts +149 -0
  20. package/src/nodes/list.ts +588 -0
  21. package/src/nodes/memo.ts +120 -0
  22. package/src/nodes/sensor.ts +139 -0
  23. package/src/nodes/state.ts +135 -0
  24. package/src/nodes/store.ts +383 -0
  25. package/src/nodes/task.ts +146 -0
  26. package/src/signal.ts +112 -64
  27. package/src/util.ts +26 -57
  28. package/test/batch.test.ts +96 -69
  29. package/test/benchmark.test.ts +473 -485
  30. package/test/collection.test.ts +455 -955
  31. package/test/effect.test.ts +293 -696
  32. package/test/list.test.ts +332 -857
  33. package/test/memo.test.ts +380 -0
  34. package/test/regression.test.ts +156 -0
  35. package/test/scope.test.ts +191 -0
  36. package/test/sensor.test.ts +454 -0
  37. package/test/signal.test.ts +220 -213
  38. package/test/state.test.ts +217 -271
  39. package/test/store.test.ts +346 -898
  40. package/test/task.test.ts +395 -0
  41. package/test/untrack.test.ts +167 -0
  42. package/test/util/dependency-graph.ts +2 -2
  43. package/tsconfig.build.json +11 -0
  44. package/tsconfig.json +5 -7
  45. package/types/index.d.ts +13 -15
  46. package/types/src/errors.d.ts +73 -19
  47. package/types/src/graph.d.ts +208 -0
  48. package/types/src/nodes/collection.d.ts +64 -0
  49. package/types/src/nodes/effect.d.ts +48 -0
  50. package/types/src/nodes/list.d.ts +65 -0
  51. package/types/src/nodes/memo.d.ts +57 -0
  52. package/types/src/nodes/sensor.d.ts +75 -0
  53. package/types/src/nodes/state.d.ts +78 -0
  54. package/types/src/nodes/store.d.ts +51 -0
  55. package/types/src/nodes/task.d.ts +73 -0
  56. package/types/src/signal.d.ts +43 -28
  57. package/types/src/util.d.ts +9 -16
  58. package/archive/benchmark.ts +0 -688
  59. package/archive/collection.ts +0 -310
  60. package/archive/computed.ts +0 -198
  61. package/archive/list.ts +0 -544
  62. package/archive/memo.ts +0 -140
  63. package/archive/state.ts +0 -90
  64. package/archive/store.ts +0 -357
  65. package/archive/task.ts +0 -191
  66. package/src/classes/collection.ts +0 -298
  67. package/src/classes/composite.ts +0 -171
  68. package/src/classes/computed.ts +0 -392
  69. package/src/classes/list.ts +0 -310
  70. package/src/classes/ref.ts +0 -96
  71. package/src/classes/state.ts +0 -131
  72. package/src/classes/store.ts +0 -227
  73. package/src/diff.ts +0 -138
  74. package/src/effect.ts +0 -96
  75. package/src/match.ts +0 -45
  76. package/src/resolve.ts +0 -49
  77. package/src/system.ts +0 -275
  78. package/test/computed.test.ts +0 -1126
  79. package/test/diff.test.ts +0 -955
  80. package/test/match.test.ts +0 -388
  81. package/test/ref.test.ts +0 -381
  82. package/test/resolve.test.ts +0 -154
  83. package/types/src/classes/collection.d.ts +0 -47
  84. package/types/src/classes/composite.d.ts +0 -15
  85. package/types/src/classes/computed.d.ts +0 -114
  86. package/types/src/classes/list.d.ts +0 -41
  87. package/types/src/classes/ref.d.ts +0 -48
  88. package/types/src/classes/state.d.ts +0 -61
  89. package/types/src/classes/store.d.ts +0 -51
  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 -81
package/README.md CHANGED
@@ -1,32 +1,40 @@
1
1
  # Cause & Effect
2
2
 
3
- Version 0.17.2
3
+ Version 0.18.0
4
4
 
5
- **Cause & Effect** is a tiny (~5kB gzipped), dependency-free reactive state 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,121 +206,130 @@ user.preferences.theme.set('light')
159
206
  createEffect(() => console.log('User:', user.get()))
160
207
  ```
161
208
 
162
- Dynamic properties using the `add()` and `remove()` methods:
209
+ Iterate keys using the reactive `.keys()` method to observe structural changes:
163
210
 
164
211
  ```js
165
- const settings = createStore({ autoSave: true })
166
-
167
- settings.add('timeout', 5000)
168
- settings.remove('timeout')
212
+ for (const key of user.keys()) {
213
+ console.log(key)
214
+ }
169
215
  ```
170
216
 
171
- Subscribe to hooks using the `.on()` method:
172
-
173
- ```js
174
- const user = createStore({ name: 'Alice', age: 30 })
175
-
176
- const offChange = user.on('change', changed => console.log(changed))
177
- const offAdd = user.on('add', added => console.log(added))
178
- const offRemove = user.on('remove', removed => console.log(removed))
179
-
180
- // These will trigger the respective hooks:
181
- user.add('email', 'alice@example.com') // Logs: "Added properties: ['email']"
182
- user.age.set(31) // Logs: "Changed properties: ['age']"
183
- user.remove('email') // Logs: "Removed properties: ['email']"
184
- ```
217
+ Access properties by key using `.byKey()` or via direct property access like `user.name` (enabled by the Proxy).
185
218
 
186
- To unregister hooks, call the returned cleanup functions:
219
+ Dynamic properties with `.add()` and `.remove()`:
187
220
 
188
221
  ```js
189
- offAdd() // Stop listening to add hook
190
- offChange() // Stop listening to change hook
191
- offRemove() // Stop listening to remove hook
222
+ const settings = createStore({ autoSave: true })
223
+
224
+ settings.add('timeout', 5000)
225
+ settings.remove('timeout')
192
226
  ```
193
227
 
194
228
  ### List
195
229
 
196
- 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:
197
231
 
198
232
  ```js
199
- import { List, createEffect } from '@zeix/cause-effect'
233
+ import { createList, createEffect } from '@zeix/cause-effect'
200
234
 
201
- const items = new List(['banana', 'apple', 'cherry'])
235
+ const items = createList(['banana', 'apple', 'cherry'])
202
236
 
203
- createEffect(() => console.log(`First: ${items[0].get()}`))
237
+ createEffect(() => console.log(`First: ${items.at(0)?.get()}`))
204
238
 
205
239
  items.add('date')
206
240
  items.splice(1, 1, 'orange')
207
241
  items.sort()
208
242
  ```
209
243
 
210
- Keys are stable across reordering:
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.
245
+
246
+ Keys are stable across reordering. Use `keyConfig` in options to control key generation:
211
247
 
212
248
  ```js
213
- const items = new List(['banana', 'apple'], 'item-')
214
- 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
+ )
215
258
 
259
+ const key = items.add('orange')
216
260
  items.sort()
217
- console.log(items.byKey(key)) // 'orange'
218
- console.log(items.indexOfKey(key)) // current index
261
+ console.log(items.byKey(key)?.get()) // 'orange'
262
+ console.log(items.indexOfKey(key)) // current index
219
263
  ```
220
264
 
221
- Lists have `.add()`, `.remove()` and `.on()` methods like stores. In addition, they have `.sort()` and `.splice()` methods. But unlike stores, deeply nested properties in items are not converted to individual signals.
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.
222
266
 
223
267
  ### Collection
224
268
 
225
- 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 start 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()`:
272
+
273
+ ```js
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({ changed: true, 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 start 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()`:
226
291
 
227
292
  ```js
228
- import { List, createEffect } from '@zeix/cause-effect'
293
+ import { createList } from '@zeix/cause-effect'
229
294
 
230
- const users = new List([
295
+ const users = createList([
231
296
  { id: 1, name: 'Alice', role: 'admin' },
232
297
  { id: 2, name: 'Bob', role: 'user' }
233
- ])
298
+ ], { keyConfig: u => String(u.id) })
299
+
234
300
  const profiles = users.deriveCollection(user => ({
235
301
  ...user,
236
302
  displayName: `${user.name} (${user.role})`
237
303
  }))
238
304
 
239
- createEffect(() => console.log('Profiles:', profiles.get()))
240
- console.log(userProfiles.at(0).get().displayName)
305
+ console.log(profiles.at(0)?.get().displayName)
241
306
  ```
242
307
 
243
- Async mapping is supported:
308
+ Async mapping is supported:
244
309
 
245
310
  ```js
246
- const details = users.derivedCollection(async (user, abort) => {
311
+ const details = users.deriveCollection(async (user, abort) => {
247
312
  const response = await fetch(`/users/${user.id}`, { signal: abort })
248
313
  return { ...user, details: await response.json() }
249
314
  })
250
315
  ```
251
316
 
252
- ### Ref
253
-
254
- 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:
255
318
 
256
319
  ```js
257
- import { createEffect, Ref } from '@zeix/cause-effect'
258
-
259
- const elementRef = new Ref(document.getElementById('status'))
260
-
261
- createEffect(() => console.log(elementRef.get().className))
262
-
263
- // external mutation happened
264
- 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}`)
265
323
  ```
266
324
 
267
- Use `Ref` for DOM nodes, Maps/Sets, sockets, third-party objects, etc.
268
-
269
- ## Effects
325
+ ### Effect
270
326
 
271
- 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:
272
328
 
273
329
  ```js
274
- import { State, createEffect } from '@zeix/cause-effect'
330
+ import { createState, createEffect } from '@zeix/cause-effect'
275
331
 
276
- const count = new State(42)
332
+ const count = createState(42)
277
333
 
278
334
  const cleanup = createEffect(() => {
279
335
  console.log(count.get())
@@ -283,81 +339,84 @@ const cleanup = createEffect(() => {
283
339
  cleanup()
284
340
  ```
285
341
 
286
- 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:
287
343
 
288
344
  ```js
289
- createEffect(async abort => {
290
- const res = await fetch('/api', { signal: abort })
291
- if (res.ok) console.log(await res.json())
345
+ createEffect(() => {
346
+ const timer = setInterval(() => console.log(count.get()), 1000)
347
+ return () => clearInterval(timer)
292
348
  })
293
349
  ```
294
350
 
295
- ### Error Handling: resolve() + match()
351
+ #### Error Handling: match()
296
352
 
297
- 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:
298
354
 
299
355
  ```js
300
- import { State, Task, createEffect, resolve, match } from '@zeix/cause-effect'
356
+ import { createState, createTask, createEffect, match } from '@zeix/cause-effect'
301
357
 
302
- const userId = new State(1)
303
- const userData = new Task(async (_, abort) => {
358
+ const userId = createState(1)
359
+ const userData = createTask(async (_, abort) => {
304
360
  const res = await fetch(`/api/users/${userId.get()}`, { signal: abort })
305
361
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
306
362
  return res.json()
307
363
  })
308
364
 
309
365
  createEffect(() => {
310
- match(resolve({ userData }), {
311
- ok: ({ userData: user }) => console.log('User:', user),
366
+ match([userData], {
367
+ ok: ([user]) => console.log('User:', user),
312
368
  nil: () => console.log('Loading...'),
313
369
  err: errors => console.error(errors[0])
314
370
  })
315
371
  })
316
372
  ```
317
373
 
318
- ## Signal Type Decision Tree
374
+ ## Choosing the Right Signal
319
375
 
320
376
  ```
321
- 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
322
382
 
323
- ├─ NoUse `Ref`
324
- (DOM nodes, Map/Set, Date, sockets, 3rd-party objects)
325
- │ Remember: call `.notify()` when it changes externally.
383
+ ├─ Yes, keyed collection `createCollection(applyChanges => { ... })`
384
+ (WebSocket streams, Server-Sent Events, external data feeds, etc.)
326
385
 
327
- └─ Yes? What kind of data is it?
386
+ └─ No, managed internally? What kind of data is it?
328
387
 
329
388
  ├─ *Primitive* (number/string/boolean)
330
389
  │ │
331
390
  │ ├─ Do you want to mutate it directly?
332
- │ │ └─ Yes → `State`
391
+ │ │ └─ Yes → `createState()`
333
392
  │ │
334
393
  │ └─ Is it derived from other signals?
335
394
  │ │
336
395
  │ ├─ Sync derived
337
396
  │ │ ├─ Simple/cheap → plain function (preferred)
338
- │ │ └─ Expensive/shared/stateful → `Memo`
339
- │ │
340
- │ └─ Async derived → `Task`
397
+ │ │ └─ Expensive/shared/stateful → `createMemo()`
398
+ │ │
399
+ │ └─ Async derived → `createTask()`
341
400
  │ (cancellation + memoization + pending/error state)
342
401
 
343
402
  ├─ *Plain Object*
344
403
  │ │
345
404
  │ ├─ Do you want to mutate individual properties?
346
- │ │ ├─ Yes → `Store`
347
- │ │ └─ No, whole object mutations only → `State`
405
+ │ │ ├─ Yes → `createStore()`
406
+ │ │ └─ No, whole object mutations only → `createState()`
348
407
  │ │
349
408
  │ └─ Is it derived from other signals?
350
- │ ├─ Sync derived → plain function or `Memo`
351
- │ └─ Async derived → `Task`
409
+ │ ├─ Sync derived → plain function or `createMemo()`
410
+ │ └─ Async derived → `createTask()`
352
411
 
353
412
  └─ *Array*
354
413
 
355
414
  ├─ Do you need to mutate it (add/remove/sort) with stable item identity?
356
- │ ├─ Yes → `List`
357
- │ └─ No, whole array mutations only → `State`
415
+ │ ├─ Yes → `createList()`
416
+ │ └─ No, whole array mutations only → `createState()`
358
417
 
359
418
  └─ Is it derived / read-only transformation of a `List` or `Collection`?
360
- └─ Yes → `Collection`
419
+ └─ Yes → `.deriveCollection()`
361
420
  (memoized + supports async mapping + chaining)
362
421
  ```
363
422
 
@@ -368,12 +427,12 @@ Is the value managed *inside* the reactive system?
368
427
  Group multiple signal updates, ensuring effects run only once after all changes are applied:
369
428
 
370
429
  ```js
371
- import { batchSignalWrites, State } from '@zeix/cause-effect'
430
+ import { batch, createState } from '@zeix/cause-effect'
372
431
 
373
- const a = new State(2)
374
- const b = new State(3)
432
+ const a = createState(2)
433
+ const b = createState(3)
375
434
 
376
- batchSignalWrites(() => {
435
+ batch(() => {
377
436
  a.set(4)
378
437
  b.set(5)
379
438
  })
@@ -381,16 +440,16 @@ batchSignalWrites(() => {
381
440
 
382
441
  ### Cleanup
383
442
 
384
- 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.
385
444
 
386
445
  ```js
387
- import { State, createEffect } from '@zeix/cause-effect'
446
+ import { createState, createEffect } from '@zeix/cause-effect'
388
447
 
389
- const user = new State({ name: 'Alice', age: 30 })
448
+ const user = createState({ name: 'Alice', age: 30 })
390
449
  const greeting = () => `Hello ${user.get().name}!`
391
450
  const cleanup = createEffect(() => {
392
- console.log(`${greeting()} You are ${user.get().age} years old`)
393
- return () => console.log('Cleanup') // Cleanup function
451
+ console.log(`${greeting()} You are ${user.get().age} years old`)
452
+ return () => console.log('Cleanup')
394
453
  })
395
454
 
396
455
  // When you no longer need the effect, execute the cleanup function
@@ -399,109 +458,71 @@ cleanup() // Logs: 'Cleanup' and unsubscribes from signal `user`
399
458
  user.set({ name: 'Bob', age: 28 }) // Won't trigger the effect anymore
400
459
  ```
401
460
 
402
- ### Resource Management with Hooks
461
+ ### Scoped Cleanup
403
462
 
404
- All signals support the `watch` hook 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:
405
464
 
406
465
  ```js
407
- import { State, createEffect } from '@zeix/cause-effect'
408
-
409
- const config = new State({ apiUrl: 'https://api.example.com' })
410
-
411
- // Set up lazy resource management
412
- config.on('watch', () => {
413
- console.log('Setting up API client...')
414
- const client = new ApiClient(config.get().apiUrl)
415
-
416
- // Return cleanup function
417
- return () => {
418
- console.log('Cleaning up API client...')
419
- client.disconnect()
420
- }
421
- })
466
+ import { createState, createEffect, createScope } from '@zeix/cause-effect'
422
467
 
423
- // Resource is created only when effect runs
424
- const cleanup = createEffect(() => {
425
- console.log('API URL:', config.get().apiUrl)
468
+ const dispose = createScope(() => {
469
+ const count = createState(0)
470
+ createEffect(() => console.log(count.get()))
471
+ return () => console.log('Scope disposed')
426
472
  })
427
473
 
428
- // Resource is cleaned up when effect stops
429
- cleanup()
474
+ dispose() // Cleans up the effect and runs the returned cleanup
430
475
  ```
431
476
 
432
- This pattern is ideal for:
433
- - Event listeners that should only be active when data is being watched
434
- - Network connections that can be lazily established
435
- - Expensive computations that should pause when not needed
436
- - External subscriptions (WebSocket, Server-Sent Events, etc.)
437
-
438
- ### resolve()
477
+ ### Resource Management with Watch Callbacks
439
478
 
440
- Extract signal values:
479
+ Sensor and Collection signals use a **start 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:
441
480
 
442
481
  ```js
443
- import { State, Memo, resolve } from '@zeix/cause-effect'
444
-
445
- const name = new State('Alice')
446
- const age = new Memo(() => 30)
447
- const result = resolve({ name, age })
448
-
449
- if (result.ok) console.log(result.values.name, result.values.age)
450
- else if (result.pending) console.log('Loading...')
451
- else console.error('Errors:', result.errors)
452
- ```
453
-
454
- ### match()
455
-
456
- Pattern matching on resolved results for side effects:
457
-
458
- ```js
459
- import { resolve, match } from '@zeix/cause-effect'
460
-
461
- match(resolve({ name, age }), {
462
- ok: ({ name, age }) => document.title = `${name} (${age})`,
463
- nil: () => document.title = 'Loading...',
464
- err: errors => document.title = `Error: ${errors[0].message}`
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)
465
490
  })
466
- ```
467
-
468
- ### diff()
469
491
 
470
- Compare object changes:
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 })
471
498
 
472
- ```js
473
- import { diff } from '@zeix/cause-effect'
474
-
475
- const oldUser = { name: 'Alice', age: 30, city: 'Boston' }
476
- const newUser = { name: 'Alice', age: 31, email: 'alice@example.com' }
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
+ })
477
504
 
478
- const changes = diff(oldUser, newUser)
479
- console.log(changes.changed) // true - something changed
480
- console.log(changes.add) // { email: 'alice@example.com' }
481
- console.log(changes.change) // { age: 31 }
482
- console.log(changes.remove) // { city: UNSET }
505
+ // Resources are cleaned up when effect stops
506
+ cleanup()
483
507
  ```
484
508
 
485
- ### isEqual()
486
-
487
- Deep equality comparison with circular reference detection:
509
+ Store and List signals support an optional `watched` callback in their options that returns a cleanup function:
488
510
 
489
511
  ```js
490
- import { isEqual } from '@zeix/cause-effect'
491
-
492
- const obj1 = { name: 'Alice', preferences: { theme: 'dark' } }
493
- const obj2 = { name: 'Alice', preferences: { theme: 'dark' } }
494
- const obj3 = { name: 'Bob', preferences: { theme: 'dark' } }
495
-
496
- console.log(isEqual(obj1, obj2)) // true - deep equality
497
- console.log(isEqual(obj1, obj3)) // false - names differ
498
-
499
- // Handles arrays, primitives, and complex nested structures
500
- console.log(isEqual([1, 2, 3], [1, 2, 3])) // true
501
- console.log(isEqual('hello', 'hello')) // true
502
- console.log(isEqual({ a: [1, 2] }, { a: [1, 2] })) // true
512
+ const user = createStore({ name: 'Alice' }, {
513
+ watched: () => {
514
+ const ws = new WebSocket('/updates')
515
+ return () => ws.close()
516
+ }
517
+ })
503
518
  ```
504
519
 
520
+ This pattern is ideal for:
521
+ - Event listeners that should only be active when data is being watched
522
+ - Network connections that can be lazily established
523
+ - Expensive computations that should pause when not needed
524
+ - External subscriptions (WebSocket, Server-Sent Events, etc.)
525
+
505
526
  ## Contributing & License
506
527
 
507
528
  Feel free to contribute, report issues, or suggest improvements.