[]previous and next page

A reactive primitive: useWritableMemo

Some state is derived by default and overridden on demand (a form field initialized from a prop, a selection that resets when the list changes).

For agentsUpdated

Some state is derived by default and overridden on demand (a form field initialized from a prop, a selection that resets when the list changes). That is a memo plus an override cell that the memo clears when it recomputes, which needs createComputation, a second cell and the one sanctioned write inside a memo.

src/use-writable-memo.tsts
import {
  type Accessor,
  createComputation,
  createSignalNode,
  readComputation,
  readSignal,
  ShadowError,
  writeSignal,
} from "@shadow-js/core/extend";
import { isDev } from "@shadow-js/core";

export interface WritableMemoOptions<T> {
  equals?: (prev: T, next: T) => boolean;
  name?: string;
}

/**
 * Like `useMemo`, but the value can be replaced with `set`. The replacement lasts until a dependency of
 * `compute` changes; then the computed value wins again.
 */
export function useWritableMemo<T>(
  compute: () => T,
  options: WritableMemoOptions<T> = {},
): [Accessor<T>, (next: T) => void] {
  if (typeof compute !== "function" && isDev) {
    // Third-party code reuses the framework's error type so tools can handle it uniformly.
    throw new ShadowError("CONTEXT_MISSING");
  }
  // Boxed, so that overriding with `undefined` differs from "no override".
  const override = createSignalNode<{ value: T } | undefined>(undefined, false, options.name);
  const memo = createComputation({
    kind: "memo",
    fn: () => {
      const next = compute();
      // Bookkeeping that belongs to this primitive, so it may happen while a memo computes.
      writeSignal(override, undefined, { allowInMemo: true });
      return next;
    },
    ...(options.equals === undefined ? {} : { equals: options.equals as (a: unknown, b: unknown) => boolean }),
    ...(options.name === undefined ? {} : { name: options.name }),
  });
  const read: Accessor<T> = () => {
    // Read the memo first: refreshing it is what clears a stale override.
    const computed = readComputation<T>(memo);
    const replaced = readSignal(override);
    return replaced === undefined ? computed : replaced.value;
  };
  const set = (next: T): void => {
    // A plain write: in development it throws WRITE_IN_MEMO if called from inside a memo.
    writeSignal(override, { value: next });
  };
  return [read, set];
}

What to copy from it:

  • A memo node is read with readComputation(node), never by calling the node. Reading refreshes a stale memo first, so order your reads accordingly.
  • allowInMemo: true on writeSignal is for cells your primitive owns and writes during its own computation. The user-facing setter keeps the default so the development guard still protects the caller.
  • equals: false on a cell means "every write is a change".
  • A memo created in an owner is disposed with it: it unsubscribes from its sources and compute stops running.

track and notify: wrapping a mutable object#

When the value is a mutable object that changes in place, keep one version cell: track it in readers and notify it after a mutation. This is how useStore tracks properties.

src/use-reactive-set.tsts
import { createSignalNode, notify, track } from "@shadow-js/core/extend";

/** A `Set` whose reads subscribe to its membership. */
export function useReactiveSet<T>(initial: Iterable<T> = []) {
  const items = new Set<T>(initial);
  // `false`: every notify is a change. The value is never read; the cell only carries observers.
  const version = createSignalNode<number>(0, false, "reactive-set");
  return {
    has(item: T): boolean {
      track(version);
      return items.has(item);
    },
    get size(): number {
      track(version);
      return items.size;
    },
    add(item: T): void {
      if (items.has(item)) return;
      items.add(item);
      notify(version);
    },
    delete(item: T): void {
      if (items.delete(item)) notify(version);
    },
  };
}

This coarse version reruns every reader of the set for any change. Finer granularity means one cell per key that has been read, created lazily on the first tracked read (useStore does that).