@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
311 lines (244 loc) • 11.2 kB
Markdown
<overview>
What each signal type in @zeix/cause-effect is for, when to use each one, and how to choose between similar types. All knowledge is embedded — no external files required.
</overview>
<signal_catalog>
<State>
**What it is:** A mutable reactive value you own and update explicitly.
**Use when:**
- You control when and how the value changes
- The value is UI state, form input, a counter, a toggle, a selection
- You need to write to it directly with `.set()`
**Key facts:**
- Requires an initial value
- Synchronous reads and writes
- Supports `equals` (default `===`) and `guard` options
```typescript
const count = createState(0)
count.set(count.get() + 1)
count.update(n => n + 1) // if update helper exists; else use set
```
</State>
<Sensor>
**What it is:** A reactive value produced by an external source you don't control.
**Use when:**
- The value comes from outside the reactive graph: DOM events, timers, WebSocket messages, geolocation, device orientation, IntersectionObserver, etc.
- You need `watched`/`unwatched` hooks to start and stop the external subscription efficiently
- The value has no meaningful initial state before the source fires
**Key facts:**
- Starts **unset** — reading before the first value throws `UnsetSignalValueError`; use `match` to handle the initial state
- `watched` fires when the first downstream effect subscribes; `unwatched` fires when the last one unsubscribes
- The setup function receives a `set` callback; return a cleanup function to tear down the subscription
```typescript
const pointer = createSensor<{ x: number; y: number }>(set => {
const handler = (e: PointerEvent) => set({ x: e.clientX, y: e.clientY })
window.addEventListener('pointermove', handler)
return () => window.removeEventListener('pointermove', handler)
})
```
</Sensor>
<Memo>
**What it is:** A synchronously derived value that stays in sync with its dependencies.
**Use when:**
- The value can be computed from other signals without async work
- You want to avoid recomputing an expensive derivation on every read
- You need a stable reference: Memo caches the last computed value and only recomputes when dependencies change
**Key facts:**
- Lazy — only recomputes when read after a dependency has changed
- Receives `prev` as its first argument (enables referential stability patterns)
- Supports `equals` to suppress downstream propagation when the new value is equivalent
```typescript
const fullName = createMemo(() => `${firstName.get()} ${lastName.get()}`)
```
</Memo>
<Task>
**What it is:** An asynchronously derived value — like Memo, but async.
**Use when:**
- The derivation requires `await` (data fetching, async transforms, indexed DB reads)
- You want automatic cancellation of in-flight work when dependencies change
**Key facts:**
- Starts **unset** until the first async operation completes; use `match` for the loading state
- Receives `(prev, signal: AbortSignal)` — always forward `signal` to `fetch` or any cancellable async operation to prevent stale responses overwriting fresh ones
- Re-runs automatically when tracked dependencies change, aborting the previous run
```typescript
const results = createTask(async (prev, signal) => {
const res = await fetch(`/api/search?q=${query.get()}`, { signal })
return res.json()
})
```
</Task>
<Effect>
**What it is:** A side effect that runs when its tracked dependencies change.
**Use when:**
- You need to synchronise reactive state with the outside world: update the DOM, write to localStorage, send analytics, call an imperative library
- You need a reactive subscription that runs code (not just derives a value)
**Key facts:**
- **Must be created inside an owner** (`createScope` or another effect) — throws `RequiredOwnerError` otherwise
- Runs immediately on creation, then re-runs on dependency changes
- Returns a `Cleanup` function; calling it disposes the effect and all its children
- Use `createScope(fn, { root: true })` in `connectedCallback` for DOM-managed lifetimes — the returned `dispose` goes in `disconnectedCallback`; the scope is never silently disposed by a re-running outer effect
```typescript
const dispose = createScope(() => {
createEffect(() => {
document.title = pageTitle.get()
})
})
// later: dispose()
```
</Effect>
<Slot>
**What it is:** A reactive property descriptor — a signal packaged as a getter/setter pair compatible with `Object.defineProperty`.
**Use when:**
- You need to attach a reactive value as a property on an object (e.g. a Web Component's observed attribute)
- You want property access (`element.name`) to participate in the reactive graph
**Key facts:**
- Has `get`, `set`, `configurable`, and `enumerable` fields — pass directly to `Object.defineProperty`
- Delegates to a swappable backing signal (any signal type); use `replace(nextSignal)` to swap
- Forwarding layer only — has no `update()` method
```typescript
const nameState = createState('Alice')
const nameSlot = createSlot(nameState)
Object.defineProperty(element, 'name', nameSlot)
```
</Slot>
<Store>
**What it is:** A reactive object whose properties are individually reactive.
**Use when:**
- You have a group of related values that are read and updated independently
- You want fine-grained reactivity on an object's fields rather than replacing the whole object
**Key facts:**
- Reading a property inside an effect creates a dependency on that property only
- Updating one property does not re-run effects that only read other properties
```typescript
const user = createStore({ name: 'Alice', age: 30 })
user.name = 'Bob' // only effects reading `user.name` re-run
```
</Store>
<List>
**What it is:** An ordered, keyed reactive collection — an array where each item has a stable identity.
**Use when:**
- Order matters and items have identity (drag-and-drop lists, ranked results, timelines)
- You need to react to structural changes (items added, removed, reordered) as well as value changes
**Key facts:**
- Items are identified by a stable key; keys survive sorting and reordering
- `byKey()`, `at()`, `keyAt()`, and `indexOfKey()` are direct lookups — they **do not create graph edges**
- To react to structural changes, read `get()`, `keys()`, or `length` instead
- To update an existing item, use `list.replace(key, value)` — **not** `byKey(key).set(value)`. `replace()` propagates to all subscribers; `byKey().set()` silently misses effects that subscribed via `keys()`, `length`, or the iterator
```typescript
const todos = createList(
[{ id: 't1', text: 'Buy milk', done: false }],
{ keyConfig: todo => todo.id }
)
todos.add({ id: 't2', text: 'Walk dog', done: false })
todos.replace('t1', { id: 't1', text: 'Buy milk', done: true }) // update in place
todos.remove('t2')
```
</List>
<Collection>
**What it is:** A keyed reactive collection — a reactive Map.
**Use when:**
- Items are identified by key and order is not meaningful or variable
- You need fast key-based lookup with reactive tracking on the key set and individual entries
- Use cases: entity caches, normalised data stores, lookup tables
**Key facts:**
- `createCollection` creates a collection from an initial set of entries
- `deriveCollection` creates a collection derived from another reactive source
- Same tracking rules as List: `byKey()` does not create graph edges; read `get()`, `keys()`, or `length` to subscribe to structural changes
```typescript
const users = createCollection<string, User>(
existingUsers.map(u => [u.id, u])
)
```
</Collection>
</signal_catalog>
<decision_guide>
<choose_by_value_source>
**Who controls the value?**
- You set it explicitly → **State**
- An external event or subscription provides it → **Sensor**
- It is computed from other signals (sync) → **Memo**
- It is computed from other signals (async) → **Task**
</choose_by_value_source>
<choose_by_purpose>
**What do you need to do with it?**
- Read a derived value without side effects → **Memo** or **Task**
- Run a side effect when something changes → **Effect**
- Expose a reactive value as an object property → **Slot**
- Group related reactive values on an object → **Store**
- Maintain an ordered list of keyed items → **List**
- Maintain an unordered map of keyed items → **Collection**
</choose_by_purpose>
<direct_comparisons>
**State vs Sensor**
Use `State` when you call `.set()` yourself. Use `Sensor` when an external source calls the setter — the library manages the subscription lifecycle via `watched`/`unwatched`.
**Memo vs Task**
Use `Memo` for synchronous derivations. Use `Task` when derivation requires `await`. Both receive `prev` and both support `equals`.
**Memo vs Effect**
`Memo` derives a value (no side effects, lazy). `Effect` runs side effects (imperative, eager, requires owner).
**State vs Store**
Use `State` for a single primitive or object that is always replaced wholesale. Use `Store` for an object whose individual properties are read and updated independently — Store gives you field-level reactivity.
**List vs Collection**
Both are keyed. Use `List` when order is significant (rendering order, ranking, sorting). Use `Collection` when items are looked up by key and order is not meaningful.
**List / Collection vs Store**
Use `Store` for a fixed set of named properties on a single object. Use `List` or `Collection` for a dynamic number of items with uniform shape.
</direct_comparisons>
</decision_guide>
<common_patterns>
<loading_state>
Sensor and Task start unset. Use `match` to handle all states in one expression.
Routing precedence: `nil` > `err` > `stale` > `ok`. `stale` fires when all signals have a retained value but at least one Task is re-fetching — omitting it falls back to `ok`:
```typescript
createEffect(() => {
match(task, {
ok: data => renderData(data),
stale: () => {
dimContent()
return clearDimmed
},
err: error => renderError(error),
nil: () => renderSpinner(),
})
})
```
For two or more signals, use the tuple form — `ok` receives a typed tuple:
```typescript
createEffect(() => {
match([task, sensor], {
ok: ([data, pos]) => render(data, pos),
nil: () => renderSpinner(),
})
})
```
</loading_state>
<grouping_effects>
Always wrap top-level effects in `createScope` to control their lifetime:
```typescript
const dispose = createScope(() => {
createEffect(() => { /* ... */ })
createEffect(() => { /* ... */ })
})
// When done (e.g. component unmounted):
dispose()
```
</grouping_effects>
<coalescing_updates>
Use `batch` when multiple state writes should trigger only one downstream propagation:
```typescript
batch(() => {
x.set(1)
y.set(2)
z.set(3)
// downstream effects run once, after all three are set
})
```
</coalescing_updates>
<reading_without_subscribing>
Use `untrack` to read a signal's current value without creating a dependency edge:
```typescript
createEffect(() => {
const primary = primary.get() // tracked — re-runs when primary changes
const snapshot = untrack(() => log.get()) // not tracked — just reads current value
console.log(primary, snapshot)
})
```
</reading_without_subscribing>
</common_patterns>