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).
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.
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: trueonwriteSignalis 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: falseon a cell means "every write is a change".- A memo created in an owner is disposed with it: it unsubscribes from its sources and
computestops 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.
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).