@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
202 lines (170 loc) • 7.72 kB
Markdown
<overview>
Key API constraints, defaults, and callback patterns for @zeix/cause-effect.
This is the shared base reference for both consumer and developer contexts.
For consumer projects, this is self-contained. For library development, see
cause-effect-dev/references/ for additional internal details.
</overview>
<type_constraint>
**`T extends {}`** — all signal generics exclude `null` and `undefined` at the type level.
This is intentional: signals always have a value; absence must be modelled explicitly.
```typescript
// Wrong — TypeScript will reject this
const count = createState<number | null>(null)
// Correct — use a sentinel or a wrapper type
const count = createState<number>(0)
const selected = createState<{ id: string } | { id: never }>({ id: '' })
```
</type_constraint>
<core_functions>
**`createScope(fn, options?)`**
- Returns a single `Cleanup` function
- `fn` receives no arguments and may return an optional cleanup that runs when the scope is disposed
- Used to group effects and control their shared lifetime
- `options.root = true` (`ScopeOptions`) — suppresses parent-owner registration; the returned `dispose` is the sole teardown mechanism. Use for scopes whose lifecycle is controlled externally (e.g. a web component's `disconnectedCallback`)
```typescript
const dispose = createScope(() => {
createEffect(() => console.log(count.get()))
// all effects inside are disposed when dispose() is called
})
dispose() // cleans up everything inside
```
**`createEffect(fn)`**
- Returns a `Cleanup` function
- **Must be called inside an owner** (another effect or a scope) — throws `RequiredOwnerError` otherwise
- `fn` runs immediately and re-runs whenever its tracked dependencies change
- Registers cleanup with the current `activeOwner`
**`batch(fn)`**
- Defers the reactive flush until `fn` returns
- Multiple state writes inside `fn` coalesce into a single propagation pass
- Use when updating several signals that feed the same downstream computation
```typescript
batch(() => {
x.set(1)
y.set(2)
z.set(3)
// only one propagation pass runs after all three writes
})
```
**`untrack(fn)`**
- Runs `fn` without recording dependency edges (nulls `activeSink`)
- Reads inside `fn` do not subscribe the current computation to those signals
- Use to read a signal's current value without creating a reactive dependency
```typescript
createEffect(() => {
const a = reactive.get() // tracked — effect re-runs when reactive changes
const b = untrack(() => other.get()) // untracked — no dependency on other
render(a, b)
})
```
**`unown(fn)`**
- Runs `fn` without registering cleanups in the current owner (nulls `activeOwner`)
- For creating a scope with an external lifecycle authority, prefer `createScope(fn, { root: true })` — it is equivalent to `unown(() => createScope(fn))` but more readable
- Use `unown` directly when detaching non-scope computations from the current owner
</core_functions>
<options>
**`equals`**
- Available on `createState`, `createSensor`, `createMemo`, `createTask`
- Default: strict equality (`===`)
- When a new value is considered equal to the previous one, propagation stops —
downstream nodes are not re-run
- **`SKIP_EQUALITY`** — special sentinel value for `equals`; forces propagation on every
update regardless of value. Use with mutable-reference sensors where the reference
never changes but the contents do:
```typescript
import { createSensor, SKIP_EQUALITY } from '@zeix/cause-effect'
const mouse = createSensor<{ x: number; y: number }>(
set => {
const handler = (e: MouseEvent) => set({ x: e.clientX, y: e.clientY })
window.addEventListener('mousemove', handler)
return () => window.removeEventListener('mousemove', handler)
},
{ equals: SKIP_EQUALITY } // new object every time, so skip reference equality
)
```
**`guard`**
- Available on `createState`, `createSensor`
- A predicate `(value: unknown) => value is T`
- Throws `InvalidSignalValueError` if a set value fails the predicate
- Use to enforce runtime type safety at signal boundaries
```typescript
const age = createState(0, {
guard: (v): v is number => typeof v === 'number' && v >= 0,
})
```
</options>
<callback_patterns>
**Memo and Task callbacks receive `prev`**
- Signature: `(prev: T) => T` for Memo; `(prev: T, signal: AbortSignal) => Promise<T>` for Task
- `prev` is the previous computed value, enabling reducer-style patterns without external state:
```typescript
const runningTotal = createMemo((prev: number) => prev + newValue.get())
```
**Task carries an `AbortSignal`**
- The second argument to the Task callback is an `AbortSignal`
- The signal is aborted when dependencies change before the previous async run completes
- Always forward it to any `fetch` or cancellable async operation:
```typescript
const results = createTask(async (prev, signal) => {
const res = await fetch(`/api/search?q=${query.get()}`, { signal })
return res.json()
})
```
**`Slot` is a property descriptor**
- Has `get`, `set`, `configurable`, `enumerable` fields — pass directly to `Object.defineProperty()`
- Delegates reads and writes to a swappable backing signal; use `replace(nextSignal)` to swap
- Is a forwarding layer, not a value owner — has no `update()` method
```typescript
const nameState = createState('Alice')
const nameSlot = createSlot(nameState)
Object.defineProperty(element, 'name', nameSlot)
```
</callback_patterns>
<match_helper>
`match` reads one or more Sensor/Task signals and routes to a handler based on signal state.
**Routing precedence:** `nil` > `err` > `stale` > `ok`
**Handlers:**
- `nil` — at least one signal has no value yet (loading)
- `err` — at least one signal has an error
- `stale` — all signals have a value but at least one Task is re-fetching (`isPending() === true`). Omitting `stale` falls back to `ok`, showing retained data unchanged. Cleanup returned by `stale` runs before the next handler fires.
- `ok` — all signals have a settled value
**Single-signal form** — `ok` receives the value directly, `err` a single `Error`:
```typescript
createEffect(() => {
match(task, {
ok: data => render(data),
stale: () => {
dimContent()
return clearDimmed
},
nil: () => showSpinner(),
err: error => showError(error),
})
})
```
**Tuple form** — for two or more signals; `ok` receives a typed tuple, `err` an `Error[]`:
```typescript
createEffect(() => {
match([task, sensor], {
ok: ([result, value]) => render(result, value),
nil: () => showSpinner(),
})
})
```
Read all signals eagerly in the signals argument — not inside branches. See
non-obvious-behaviors.md for details on conditional reads.
</match_helper>
<lifecycle_summary>
| Function | Must be in owner? | Returns | Re-runs on dependency change? |
|---|---|---|---|
| `createScope(fn, options?)` | No | `Cleanup` | No (fn runs once) |
| `createEffect(fn)` | **Yes** | `Cleanup` | Yes |
| `createMemo(fn)` | No | `Memo<T>` | Lazily (on read) |
| `createTask(fn)` | No | `Task<T>` | Yes (async) |
| `createState(value)` | No | `State<T>` | Source — never recomputes |
| `createSensor(setup)` | No | `Sensor<T>` | Source — set by external callback |
| `createSlot(signal)` | No | `Slot<T>` | Forwarding — delegates to backing signal |
| `createStore(value)` | No | `Store<T>` | Source — proxy-based |
| `createList(items, options?)` | No | `List<T>` | Source — keyed array |
| `createCollection(entries, options?)` | No | `Collection<K, V>` | Source — keyed map |
| `deriveCollection(source, callback)` | No | `Collection<K, V>` | Derived — from another reactive source |
</lifecycle_summary>