@zeix/cause-effect 0.17.3 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/.ai-context.md +163 -232
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +166 -116
  4. package/ARCHITECTURE.md +274 -0
  5. package/CLAUDE.md +199 -143
  6. package/COLLECTION_REFACTORING.md +161 -0
  7. package/GUIDE.md +298 -0
  8. package/README.md +232 -197
  9. package/REQUIREMENTS.md +100 -0
  10. package/bench/reactivity.bench.ts +577 -0
  11. package/index.dev.js +1325 -997
  12. package/index.js +1 -1
  13. package/index.ts +58 -74
  14. package/package.json +4 -1
  15. package/src/errors.ts +118 -74
  16. package/src/graph.ts +601 -0
  17. package/src/nodes/collection.ts +474 -0
  18. package/src/nodes/effect.ts +149 -0
  19. package/src/nodes/list.ts +588 -0
  20. package/src/nodes/memo.ts +120 -0
  21. package/src/nodes/sensor.ts +139 -0
  22. package/src/nodes/state.ts +135 -0
  23. package/src/nodes/store.ts +383 -0
  24. package/src/nodes/task.ts +146 -0
  25. package/src/signal.ts +112 -66
  26. package/src/util.ts +26 -57
  27. package/test/batch.test.ts +96 -62
  28. package/test/benchmark.test.ts +473 -487
  29. package/test/collection.test.ts +466 -706
  30. package/test/effect.test.ts +293 -696
  31. package/test/list.test.ts +335 -592
  32. package/test/memo.test.ts +380 -0
  33. package/test/regression.test.ts +156 -0
  34. package/test/scope.test.ts +191 -0
  35. package/test/sensor.test.ts +454 -0
  36. package/test/signal.test.ts +220 -213
  37. package/test/state.test.ts +217 -265
  38. package/test/store.test.ts +346 -446
  39. package/test/task.test.ts +395 -0
  40. package/test/untrack.test.ts +167 -0
  41. package/types/index.d.ts +13 -15
  42. package/types/src/errors.d.ts +73 -17
  43. package/types/src/graph.d.ts +208 -0
  44. package/types/src/nodes/collection.d.ts +64 -0
  45. package/types/src/nodes/effect.d.ts +48 -0
  46. package/types/src/nodes/list.d.ts +65 -0
  47. package/types/src/nodes/memo.d.ts +57 -0
  48. package/types/src/nodes/sensor.d.ts +75 -0
  49. package/types/src/nodes/state.d.ts +78 -0
  50. package/types/src/nodes/store.d.ts +51 -0
  51. package/types/src/nodes/task.d.ts +73 -0
  52. package/types/src/signal.d.ts +43 -29
  53. package/types/src/util.d.ts +9 -16
  54. package/archive/benchmark.ts +0 -683
  55. package/archive/collection.ts +0 -253
  56. package/archive/composite.ts +0 -85
  57. package/archive/computed.ts +0 -195
  58. package/archive/list.ts +0 -483
  59. package/archive/memo.ts +0 -139
  60. package/archive/state.ts +0 -90
  61. package/archive/store.ts +0 -298
  62. package/archive/task.ts +0 -189
  63. package/src/classes/collection.ts +0 -245
  64. package/src/classes/computed.ts +0 -349
  65. package/src/classes/list.ts +0 -343
  66. package/src/classes/ref.ts +0 -70
  67. package/src/classes/state.ts +0 -102
  68. package/src/classes/store.ts +0 -262
  69. package/src/diff.ts +0 -138
  70. package/src/effect.ts +0 -93
  71. package/src/match.ts +0 -45
  72. package/src/resolve.ts +0 -49
  73. package/src/system.ts +0 -257
  74. package/test/computed.test.ts +0 -1108
  75. package/test/diff.test.ts +0 -955
  76. package/test/match.test.ts +0 -388
  77. package/test/ref.test.ts +0 -353
  78. package/test/resolve.test.ts +0 -154
  79. package/types/src/classes/collection.d.ts +0 -45
  80. package/types/src/classes/computed.d.ts +0 -94
  81. package/types/src/classes/list.d.ts +0 -43
  82. package/types/src/classes/ref.d.ts +0 -35
  83. package/types/src/classes/state.d.ts +0 -49
  84. package/types/src/classes/store.d.ts +0 -52
  85. package/types/src/diff.d.ts +0 -28
  86. package/types/src/effect.d.ts +0 -15
  87. package/types/src/match.d.ts +0 -21
  88. package/types/src/resolve.d.ts +0 -29
  89. package/types/src/system.d.ts +0 -78
@@ -0,0 +1,161 @@
1
+ # Collection Refactoring Plan
2
+
3
+ ## Goal
4
+
5
+ Unify `createCollection()` and `createSourceCollection()` into a single `createCollection()` primitive whose primary form mirrors `createSensor()`: an externally-driven signal with a watched lifecycle. The derived-from-List/Collection form becomes an internal helper used by `.deriveCollection()`.
6
+
7
+ ## Motivation
8
+
9
+ - **Sensor ↔ Collection parallel**: Both are externally-driven, lazily activated, and auto-cleaned. Making their signatures parallel sharpens this mental model.
10
+ - **One primitive, one name**: Users learn `createCollection(start, options)` the same way they learn `createSensor(start, options)`.
11
+ - **Derived collections are a method, not a standalone call**: `list.deriveCollection(fn)` and `collection.deriveCollection(fn)` already exist and are the natural way to create derived collections.
12
+
13
+ ## New API Surface
14
+
15
+ ```typescript
16
+ // Primary form — externally driven (replaces createSourceCollection)
17
+ function createCollection<T extends {}>(
18
+ start: CollectionCallback<T>,
19
+ options?: CollectionOptions<T>,
20
+ ): Collection<T>
21
+
22
+ // CollectionCallback mirrors SensorCallback but receives applyChanges
23
+ type CollectionCallback<T extends {}> = (
24
+ applyChanges: (changes: DiffResult) => void,
25
+ ) => Cleanup
26
+
27
+ // CollectionOptions — initial value hidden in options (like Memo, Task, Sensor)
28
+ type CollectionOptions<T extends {}> = {
29
+ value?: T[] // initial items (default: [])
30
+ keyConfig?: KeyConfig<T> // key generation strategy
31
+ createItem?: (key: string, value: T) => Signal<T> // custom item factory
32
+ }
33
+
34
+ // Derive method — unchanged on List and Collection
35
+ collection.deriveCollection(callback)
36
+ list.deriveCollection(callback)
37
+ ```
38
+
39
+ ## Refactoring Steps
40
+
41
+ Order matters: the existing `createCollection` and `CollectionCallback` names must be freed up before they can be reused for the new concept. The refactoring proceeds in two phases.
42
+
43
+ ### Phase 1 — Rename existing symbols (free the names)
44
+
45
+ #### 1.1. Rename `createCollection` → `deriveCollection`
46
+
47
+ In `src/nodes/collection.ts`:
48
+
49
+ - Rename the function `createCollection(source, callback)` → `deriveCollection(source, callback)`.
50
+ - Update both overload signatures and the implementation signature.
51
+ - Update the internal `deriveCollection()` call inside the `Collection.deriveCollection` method body (both in the derived-collection object and the source-collection object).
52
+
53
+ #### 1.2. Rename `CollectionCallback<T, U>` → `DeriveCollectionCallback<T, U>`
54
+
55
+ - Rename the type alias in `src/nodes/collection.ts`.
56
+ - Update all references: the `deriveCollection` parameter types, and the `Collection.deriveCollection` method parameter type annotations.
57
+
58
+ #### 1.3. Update `list.ts`
59
+
60
+ - Change the import from `createCollection` to `deriveCollection`.
61
+ - Update `List.deriveCollection()` body to call `deriveCollection(list, cb)`.
62
+
63
+ #### 1.4. Update exports in `index.ts`
64
+
65
+ - Replace `createCollection` → `deriveCollection` in the export list.
66
+ - Replace `CollectionCallback` → `DeriveCollectionCallback`.
67
+ - Keep or drop `CollectionSource` from public exports (internal detail of `deriveCollection`).
68
+
69
+ #### 1.5. Update tests
70
+
71
+ - In `test/collection.test.ts` (or `test/collection.next.test.ts`): replace all direct `createCollection(source, cb)` calls with either `deriveCollection(source, cb)` or the equivalent `.deriveCollection(cb)` method.
72
+ - Update imports accordingly.
73
+
74
+ #### 1.6. Verify
75
+
76
+ - `bun run check` and `bun test` pass.
77
+ - Commit: "Rename createCollection → deriveCollection, CollectionCallback → DeriveCollectionCallback"
78
+
79
+ ### Phase 2 — Reshape `createSourceCollection` → `createCollection`
80
+
81
+ #### 2.1. Rename and reshape function
82
+
83
+ In `src/nodes/collection.ts`:
84
+
85
+ - Rename `createSourceCollection` → `createCollection`.
86
+ - Move `initialValue` from first positional arg into `options.value` (default `[]`).
87
+ - New signature: `createCollection<T>(start: CollectionCallback<T>, options?: CollectionOptions<T>)`.
88
+
89
+ #### 2.2. Rename types
90
+
91
+ - `SourceCollectionCallback` → `CollectionCallback<T>` (generic over `T` for type coherence with `CollectionOptions<T>`, even though the callback itself doesn't use `T` directly).
92
+ - `SourceCollectionOptions<T>` → `CollectionOptions<T>`, adding the `value?: T[]` field.
93
+
94
+ #### 2.3. Update exports in `index.ts`
95
+
96
+ ```typescript
97
+ // Remove
98
+ export { createSourceCollection, SourceCollectionCallback, SourceCollectionOptions, CollectionSource }
99
+
100
+ // Add / rename
101
+ export { createCollection, CollectionCallback, CollectionOptions }
102
+
103
+ // Keep
104
+ export { Collection, DiffResult, isCollection }
105
+
106
+ // Optional (if deriveCollection is exported)
107
+ export { deriveCollection, DeriveCollectionCallback }
108
+ ```
109
+
110
+ #### 2.4. Update tests
111
+
112
+ - `test/source-collection.test.ts` → rename to `test/collection.next.test.ts` (or merge into existing collection test file).
113
+ - Update all `createSourceCollection(initialValue, start, options)` calls to `createCollection(start, { value: initialValue, ...options })`.
114
+ - Update type imports: `CollectionCallback` instead of `SourceCollectionCallback`, etc.
115
+
116
+ #### 2.5. Update CLAUDE.md and docs
117
+
118
+ - Update the Collection section to present `createCollection(start, options)` as the primary form.
119
+ - Show `.deriveCollection()` as the way to transform Lists/Collections.
120
+ - Emphasize Sensor ↔ Collection parallel in the mental model section.
121
+
122
+ #### 2.6. Verify
123
+
124
+ - `bun run check` and `bun test` pass.
125
+ - Commit: "Reshape createSourceCollection → createCollection(start, options)"
126
+
127
+ ## Type Summary
128
+
129
+ ```
130
+ Before After
131
+ ───────────────────────────────── ─────────────────────────────────
132
+ createSourceCollection(init, start, opts) → createCollection(start, opts)
133
+ SourceCollectionCallback → CollectionCallback<T>
134
+ SourceCollectionOptions<T> → CollectionOptions<T>
135
+
136
+ createCollection(source, callback) → deriveCollection(source, callback) [internal]
137
+ CollectionCallback<T, U> → DeriveCollectionCallback<T, U> [internal or optional export]
138
+ CollectionSource<T> → CollectionSource<T> [internal]
139
+ ```
140
+
141
+ ## Migration Checklist
142
+
143
+ ### Phase 1 — Free the names
144
+ - [ ] Rename `createCollection(source, cb)` → `deriveCollection(source, cb)`
145
+ - [ ] Rename type `CollectionCallback<T, U>` → `DeriveCollectionCallback<T, U>`
146
+ - [ ] Update `List.deriveCollection()` and `Collection.deriveCollection()` to call `deriveCollection()`
147
+ - [ ] Update `index.ts` exports (phase 1)
148
+ - [ ] Update tests (phase 1)
149
+ - [ ] Verify: `bun run check` and `bun test` pass
150
+ - [ ] Commit phase 1
151
+
152
+ ### Phase 2 — Reclaim the names
153
+ - [ ] Rename `createSourceCollection` → `createCollection(start, options)` with `options.value`
154
+ - [ ] Rename type `SourceCollectionCallback` → `CollectionCallback<T>`
155
+ - [ ] Rename type `SourceCollectionOptions` → `CollectionOptions` (add `value?: T[]`)
156
+ - [ ] Drop `CollectionSource` from public exports
157
+ - [ ] Update `index.ts` exports (phase 2)
158
+ - [ ] Update tests (phase 2)
159
+ - [ ] Update CLAUDE.md Collection sections
160
+ - [ ] Verify: `bun run check` and `bun test` pass
161
+ - [ ] Commit phase 2
package/GUIDE.md ADDED
@@ -0,0 +1,298 @@
1
+ # Guide for Framework Developers
2
+
3
+ If you've used React, Vue, or Angular, you already understand the core idea behind Cause & Effect: state changes should automatically propagate to derived values and side effects. This guide maps what you know to how this library works, explains where the mental model diverges, and introduces capabilities that go beyond what most reactive libraries provide.
4
+
5
+ ## The Familiar Core
6
+
7
+ The three building blocks map directly to what you already use:
8
+
9
+ | Concept | React | Vue | Angular | Cause & Effect |
10
+ |---------|-------|-----|---------|----------------|
11
+ | Mutable state | `useState` | `ref()` | `signal()` | `createState()` |
12
+ | Derived value | `useMemo` | `computed()` | `computed()` | `createMemo()` |
13
+ | Side effect | `useEffect` | `watchEffect()` | `effect()` | `createEffect()` |
14
+
15
+ Here is how they work together:
16
+
17
+ ```ts
18
+ import { createState, createMemo, createEffect } from '@zeix/cause-effect'
19
+
20
+ const count = createState(0)
21
+ const doubled = createMemo(() => count.get() * 2)
22
+
23
+ createEffect(() => {
24
+ console.log(`${count.get()} doubled is ${doubled.get()}`)
25
+ })
26
+
27
+ count.set(5) // logs: "5 doubled is 10"
28
+ ```
29
+
30
+ If you've written a `computed` in Vue or a `useMemo` in React, this should feel immediately familiar. The difference is that there is no component, no template, no JSX — just reactive primitives composing directly.
31
+
32
+ ## What Works Differently
33
+
34
+ ### Dependencies are tracked, not declared
35
+
36
+ In React, you declare dependencies manually:
37
+
38
+ ```ts
39
+ // React
40
+ useEffect(() => {
41
+ console.log(count)
42
+ }, [count]) // ← you must list dependencies
43
+ ```
44
+
45
+ In Cause & Effect, calling `.get()` *is* the dependency declaration. If you read a signal inside an effect or memo, it becomes a dependency automatically. If you don't read it, it doesn't.
46
+
47
+ ```ts
48
+ // Cause & Effect
49
+ createEffect(() => {
50
+ console.log(count.get()) // ← this IS the dependency
51
+ })
52
+ ```
53
+
54
+ There are no dependency arrays to maintain, no lint rules to enforce them, and no stale closure bugs from forgetting a dependency. Vue and Angular developers will find this familiar — it works like `watchEffect()` and Angular's `effect()`.
55
+
56
+ ### Effects run synchronously
57
+
58
+ In React, effects run after the browser paints. In Vue, reactive updates are batched until the next microtask. In Cause & Effect, effects run synchronously right after a state change:
59
+
60
+ ```ts
61
+ const name = createState('Alice')
62
+
63
+ createEffect(() => {
64
+ console.log(name.get()) // runs immediately with "Alice"
65
+ })
66
+
67
+ name.set('Bob') // runs the effect again, right here, synchronously
68
+ ```
69
+
70
+ When you need to update multiple signals without triggering intermediate effects, wrap updates in `batch()`:
71
+
72
+ ```ts
73
+ import { batch } from '@zeix/cause-effect'
74
+
75
+ batch(() => {
76
+ firstName.set('Bob')
77
+ lastName.set('Smith')
78
+ }) // effect runs once, after both updates
79
+ ```
80
+
81
+ ### Non-nullable signals
82
+
83
+ All signals enforce `T extends {}` — `null` and `undefined` are excluded at the type level. This means you can trust that `.get()` always returns a real value without null checks.
84
+
85
+ ```ts
86
+ const count = createState(0)
87
+ count.get() // type is number, guaranteed non-null
88
+
89
+ // This won't compile:
90
+ // const maybeUser = createState<User | null>(null)
91
+ ```
92
+
93
+ This is a deliberate design decision. In frameworks, nullable state leads to defensive checks scattered across templates and hooks. Here, the type system prevents it.
94
+
95
+ **What to do instead:**
96
+
97
+ - For async results: use `createTask()` — a Task without reactive dependencies works like a Promise that resolves into the graph. Use `match()` to handle the pending state.
98
+ - For external input that starts undefined: use `createSensor()` with its lazy start callback.
99
+ - For optional state: use a discriminated union, an empty string, an empty array, `0`, or `false` — whatever the zero value for your type is:
100
+
101
+ ```ts
102
+ type AuthState = { status: 'anonymous' } | { status: 'authenticated', user: User }
103
+ const auth = createState<AuthState>({ status: 'anonymous' })
104
+ ```
105
+
106
+ ### Scopes replace the component tree
107
+
108
+ In React, Vue, and Angular, reactivity is tied to components. Effects clean up when components unmount. Components form a tree that manages lifetimes.
109
+
110
+ Cause & Effect has no components — but it has `createScope()`, which serves the same structural purpose. A scope captures child effects, manages their cleanup, and can be nested inside other scopes or effects:
111
+
112
+ ```ts
113
+ import { createState, createEffect, createScope } from '@zeix/cause-effect'
114
+
115
+ const dispose = createScope(() => {
116
+ const count = createState(0)
117
+
118
+ createEffect(() => {
119
+ console.log(count.get())
120
+ })
121
+
122
+ return () => console.log('scope disposed')
123
+ })
124
+
125
+ // Later: dispose everything created inside
126
+ dispose()
127
+ ```
128
+
129
+ Think of scopes as **components without rendering**. They are the building block for breaking the signal graph into smaller, manageable pieces — often driven by what needs to be looped or dynamically created. A UI framework built on this library would typically create a scope per component.
130
+
131
+ **Automatic vs. manual cleanup:**
132
+
133
+ - Inside a scope or parent effect, child effects are disposed automatically when the parent is disposed.
134
+ - Outside any owner, you must call the cleanup function returned by `createEffect()` yourself.
135
+
136
+ ```ts
137
+ // Automatic: effect is disposed when the scope is disposed
138
+ const dispose = createScope(() => {
139
+ createEffect(() => console.log(count.get()))
140
+ })
141
+ dispose() // cleans up the effect
142
+
143
+ // Manual: no parent scope, you manage the lifetime
144
+ const cleanup = createEffect(() => console.log(count.get()))
145
+ cleanup() // you must call this yourself
146
+ ```
147
+
148
+ ### Explicit equality, not reference identity
149
+
150
+ By default, signals use `===` for equality. But unlike frameworks where this is buried in internals, you can override it per signal:
151
+
152
+ ```ts
153
+ const point = createState({ x: 0, y: 0 }, {
154
+ equals: (a, b) => a.x === b.x && a.y === b.y
155
+ })
156
+
157
+ point.set({ x: 0, y: 0 }) // no update — values are equal
158
+ ```
159
+
160
+ ## Beyond the Basics
161
+
162
+ The primitives above cover what most reactive libraries provide. The following signal types address patterns that frameworks handle with ad-hoc solutions or external libraries.
163
+
164
+ ### Task: async derivations with cancellation
165
+
166
+ In React, async data fetching requires `useEffect` + cleanup + state management (or a library like React Query). In Angular, you'd use RxJS with `switchMap`. In Cause & Effect, `createTask()` is a signal that happens to be async:
167
+
168
+ ```ts
169
+ import { createState, createTask, createEffect, match } from '@zeix/cause-effect'
170
+
171
+ const userId = createState(1)
172
+
173
+ const user = createTask(async (prev, abort) => {
174
+ const res = await fetch(`/api/users/${userId.get()}`, { signal: abort })
175
+ return res.json()
176
+ })
177
+
178
+ userId.set(2) // cancels the in-flight request, starts a new one
179
+ ```
180
+
181
+ The `abort` signal is managed automatically — when dependencies change, the previous computation is cancelled. No cleanup functions to write, no race conditions to handle.
182
+
183
+ Use `match()` inside effects to handle all states declaratively:
184
+
185
+ ```ts
186
+ createEffect(() => {
187
+ match([user], {
188
+ ok: ([data]) => console.log('User:', data),
189
+ nil: () => console.log('Loading...'),
190
+ err: (errors) => console.error(errors[0])
191
+ })
192
+ })
193
+ ```
194
+
195
+ ### Store: per-property reactivity
196
+
197
+ In React, updating one property of an object re-renders everything that reads the object. In Vue, `reactive()` gives you per-property tracking — `createStore()` works the same way:
198
+
199
+ ```ts
200
+ import { createStore, createEffect } from '@zeix/cause-effect'
201
+
202
+ const user = createStore({ name: 'Alice', age: 30, email: 'alice@example.com' })
203
+
204
+ // This effect only re-runs when name changes
205
+ createEffect(() => {
206
+ console.log(user.name.get())
207
+ })
208
+
209
+ user.age.set(31) // does NOT trigger the effect above
210
+ user.name.set('Bob') // triggers it
211
+ ```
212
+
213
+ Each property becomes its own signal. Nested objects become nested stores. This is more granular than `createState({ ... })`, which would treat the whole object as a single value.
214
+
215
+ ### List: reactive arrays with stable keys
216
+
217
+ Frameworks use `key` props (React), `:key` bindings (Vue), or `track` expressions (Angular) to maintain item identity during re-renders. In Cause & Effect, `createList()` bakes stable keys into the data structure itself:
218
+
219
+ ```ts
220
+ import { createList, createEffect } from '@zeix/cause-effect'
221
+
222
+ const todos = createList([
223
+ { id: 't1', text: 'Learn signals', done: false },
224
+ { id: 't2', text: 'Build app', done: false }
225
+ ], { keyConfig: todo => todo.id })
226
+
227
+ // Get a stable reference to a specific item
228
+ const first = todos.byKey('t1')
229
+
230
+ todos.sort((a, b) => a.text.localeCompare(b.text))
231
+ // first still points to "Learn signals", regardless of position
232
+
233
+ // Update a single item without replacing the array
234
+ first?.set({ id: 't1', text: 'Learn signals', done: true })
235
+ ```
236
+
237
+ Each item is its own signal. Sorting reorders keys without destroying signals or their downstream dependencies. Adding and removing items is granular — unaffected items and their effects don't re-run.
238
+
239
+ ### Collection: derived arrays with item-level memoization
240
+
241
+ Collections provide reactive transformations over arrays with automatic per-item memoization. They come in two forms: **derived collections** (transformations of Lists or other Collections) and **externally-driven collections** (fed by external sources like WebSockets or Server-Sent Events).
242
+
243
+ **Derived collections** are created via `.deriveCollection()` on a List or Collection:
244
+
245
+ ```ts
246
+ const display = todos.deriveCollection(todo => ({
247
+ label: todo.done ? `[x] ${todo.text}` : `[ ] ${todo.text}`
248
+ }))
249
+
250
+ // Async transformations with automatic cancellation
251
+ const enriched = todos.deriveCollection(async (todo, abort) => {
252
+ const res = await fetch(`/api/details/${todo.id}`, { signal: abort })
253
+ return { ...todo, details: await res.json() }
254
+ })
255
+
256
+ // Chain collections for data pipelines
257
+ const pipeline = todos
258
+ .deriveCollection(todo => ({ ...todo, urgent: todo.priority > 8 }))
259
+ .deriveCollection(todo => todo.urgent ? `URGENT: ${todo.text}` : todo.text)
260
+ ```
261
+
262
+ When one item changes, only its derived signal recomputes. Structural changes (additions, removals) are tracked separately from value changes.
263
+
264
+ **Externally-driven collections** are created with `createCollection()` and a start callback for keyed data arriving from external sources:
265
+
266
+ ```ts
267
+ import { createCollection, createEffect } from '@zeix/cause-effect'
268
+
269
+ const messages = createCollection((applyChanges) => {
270
+ const ws = new WebSocket('/messages')
271
+ ws.onmessage = (e) => applyChanges({ changed: true, add: JSON.parse(e.data) })
272
+ return () => ws.close()
273
+ }, { keyConfig: msg => msg.id })
274
+
275
+ // Same Collection interface — .get(), .byKey(), .deriveCollection()
276
+ createEffect(() => {
277
+ console.log('Messages:', messages.get().length)
278
+ })
279
+ ```
280
+
281
+ The WebSocket connects when the first effect reads the collection and disconnects when no effects are watching. Incoming data is applied as granular add/change/remove operations, not wholesale array replacement.
282
+
283
+ ### Sensor: lazy external input
284
+
285
+ Frameworks typically manage event listeners inside component lifecycle hooks (`useEffect`, `onMounted`, `ngOnInit`). In Cause & Effect, `createSensor()` encapsulates external input with automatic resource management:
286
+
287
+ ```ts
288
+ import { createSensor, createEffect } from '@zeix/cause-effect'
289
+
290
+ const windowSize = createSensor((set) => {
291
+ const update = () => set({ w: innerWidth, h: innerHeight })
292
+ update()
293
+ window.addEventListener('resize', update)
294
+ return () => window.removeEventListener('resize', update)
295
+ })
296
+ ```
297
+
298
+ The start callback runs lazily — only when an effect first reads the sensor. When no effects are watching, the cleanup runs automatically. When an effect reads it again, the start callback runs again. No manual setup/teardown.