UNPKG

@zeix/cause-effect

Version:

Cause & Effect - reactive state management primitives library for TypeScript.

311 lines (244 loc) 11.2 kB
<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>