@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
204 lines (168 loc) • 8.15 kB
Markdown
<overview>
Counterintuitive behaviors in /cause-effect that commonly cause bugs or confusion.
This is the shared base reference for both consumer and developer contexts.
For library development, see cause-effect-dev/references/ for additional internal
implementation details.
</overview>
<direct_lookups_do_not_track>
**`byKey()`, `at()`, `keyAt()`, and `indexOfKey()` do not create graph edges.** They are
direct lookups into the internal map/array — calling them inside an effect or memo does not
subscribe to structural changes.
To react to structural changes (key added, key removed, order changed), read a tracking
accessor instead:
| You want to react to | Read this |
|---|---|
| Any structural change | `collection.get()` or `list.get()` |
| Key set membership | `collection.keys()` |
| Length / item count | `collection.length` |
| A specific item's value | `collection.get()` then access the item |
```typescript
// Wrong — effect does not re-run when keys are added or removed
createEffect(() => {
const item = collection.byKey('id-123')
render(item)
})
// Correct — reading keys() creates a dependency on structural changes
createEffect(() => {
const keys = collection.keys() // tracks structure
const item = collection.byKey('id-123') // safe after establishing the edge
render(item)
})
```
</direct_lookups_do_not_track>
<bykey_set_does_not_propagate_to_structural_subscribers>
**`byKey(key).set(value)` does not propagate to effects that subscribed via `list.keys()`,
`list.length`, or the iterator.** Those effects subscribe to the list's structural node but
do not establish item-level edges, so a direct item signal mutation reaches them only if
`list.get()` has previously been called to link the item signal to the list node.
Use `list.replace(key, value)` for imperative item updates. It propagates through both paths
— item-level edges and the structural node — regardless of how subscribers are attached.
```typescript
// Wrong — silently does nothing for effects that subscribed via list.keys()
list.byKey(key)?.set(newValue)
// Correct — guaranteed propagation to all subscribers
list.replace(key, newValue)
```
`byKey(key).set(value)` is safe only when the consuming effect directly calls
`byKey(key).get()` inside its body — that creates a direct edge from the item signal to the
effect, bypassing the list node entirely.
**Internal note:** `byKey(key).set(value)` does not propagate through `listNode.sinks`
unless `itemSignal -> listNode` edges exist. Those edges are established only when
`recomputeMemo(listNode)` runs — which requires `list.get()` to have been called.
</bykey_set_does_not_propagate_to_structural_subscribers>
<conditional_reads_delay_watched>
**Conditional signal reads delay `watched` activation.** The `watched` callback on a State
or Sensor fires when the first downstream effect subscribes. If a signal is only read inside
a branch that hasn't executed yet, `watched` does not fire until that branch runs.
Read all signals you care about eagerly — before any conditional logic — to ensure `watched`
fires on the first effect run:
```typescript
// Bad — `derived` is only read after `task` resolves to `ok`
// `derived.watched` does not fire until the task has a value
createEffect(() => {
match(task, {
ok: result => render(derived.get(), result),
nil: () => showSpinner(),
})
})
// Good — both signals are read on every run, regardless of task state
// Both `watched` callbacks fire immediately when the effect is created
createEffect(() => {
match([task, derived], {
ok: ([result, value]) => render(value, result),
nil: () => showSpinner(),
})
})
```
This also applies to plain `if` / ternary / `&&` patterns — any signal read gated behind a
condition may not establish its dependency edge until the condition is true.
</conditional_reads_delay_watched>
<equals_suppresses_subtrees>
**`equals` suppresses entire downstream subgraphs, not just the node it is set on.** When a
Memo or State recomputes to a value considered equal to the previous one, all downstream
nodes receive `FLAG_CHECK` instead of `FLAG_DIRTY`. Those nodes skip recomputation entirely
without running their callbacks.
This is a powerful optimization, but it has a non-obvious consequence: a custom `equals` on
an intermediate Memo can silently prevent large parts of the graph from updating, even if
upstream sources changed.
```typescript
const source = createState({ x: 1, y: 2 })
// This memo compares by x only
const xOnly = createMemo(
() => source.get().x,
{ equals: (a, b) => a === b }
)
// This effect depends on xOnly
// It will NOT re-run if source changes but x stays the same,
// even if y changed dramatically
createEffect(() => {
console.log('x is', xOnly.get())
})
```
When debugging "why did my effect not re-run", check for custom `equals` on intermediate
memos in the dependency chain.
**Internal note:** When a new value is `equals` to the previous, downstream nodes skip
recomputation entirely without running their callbacks.
</equals_suppresses_subtrees>
<watched_stable_through_mutations>
**`watched` stays active through structural mutations.** The `watched` callback on a List or
Collection source is called once when the first downstream effect subscribes, and `unwatched`
is called when the last downstream effect unsubscribes. Structural mutations (adding items,
removing items, updating values) do not call `unwatched` then `watched` again — the callback
remains active for the lifetime of the subscription.
```typescript
const list = createList(
() => startPolling(), // watched: called once when first effect subscribes
() => stopPolling(), // unwatched: called once when last effect unsubscribes
)
// These mutations do NOT restart the watched/unwatched cycle.
// The data source stays open as long as at least one effect is subscribed.
list.push({ id: '1', name: 'Item 1' }) // watched is NOT called again
list.delete('1') // watched is NOT called again
```
</watched_stable_through_mutations>
<task_abort_on_dependency_change>
**A Task's `AbortSignal` is aborted when dependencies change before the async operation
completes.** If a Task's sources update while the previous `Promise` is still pending, a new
run is scheduled and the previous `AbortController` is aborted. Not forwarding the signal to
cancellable async operations will cause stale results to overwrite fresh ones.
```typescript
// Wrong — fetch is not cancellable; stale response may arrive after a newer one
const results = createTask(async () => {
return fetch(`/api/search?q=${query.get()}`).then(r => r.json())
})
// Correct — abort signal forwarded; stale requests are cancelled
const results = createTask(async (prev, signal) => {
return fetch(`/api/search?q=${query.get()}`, { signal }).then(r => r.json())
})
```
</task_abort_on_dependency_change>
<sensor_unset_before_first_value>
**Reading a Sensor or Task before it has produced a value throws `UnsetSignalValueError`.**
Unlike State, these signals have no initial value — they are explicitly "unset" until the
first value arrives.
Guard against this with `match`, which provides a `nil` branch for the unset case:
```typescript
const tick = createSensor<number>(set => {
const id = setInterval(() => set(Date.now()), 1000)
return () => clearInterval(id)
})
// Wrong — throws UnsetSignalValueError on first run, before the interval fires
createEffect(() => {
console.log(tick.get())
})
// Correct — match handles the nil (unset) case explicitly
createEffect(() => {
match(tick, {
ok: timestamp => console.log('tick:', timestamp),
nil: () => console.log('waiting for first tick…'),
})
})
```
</sensor_unset_before_first_value>
<scope_cleanup_is_synchronous>
**Scope and Effect cleanup runs synchronously when the returned `Cleanup` function is
called.** It does not wait for the current flush to complete. Calling cleanup during a flush
(e.g. inside a batch callback) is safe but will immediately dispose the owner and all its
children.
</scope_cleanup_is_synchronous>