API reference
Every public export of @dirtytalk/structural and @dirtytalk/structural/react with TypeScript signatures, parameters, returns and examples.
Reference for all exports of @dirtytalk/structural (core) and @dirtytalk/structural/react. For explanations see Containers, Tracking and React.
Container
StructuralContainer
abstract class StructuralContainer<S> {
static getInternerFor(ctor: object): PathInterner;
constructor(initial: S, options?: StructuralContainerOptions);
get state(): S;
get interner(): PathInterner;
get channel(): DirtyChannel<PathSet>;
get consumerCount(): number;
getConsumerPaths(): ReadonlyMap<ConsumerId, PathSet>;
dispose(): void;
subscribe(interest: () => PathSet, cb: (dirty: PathSet) => void): () => void;
subscribePaths(cb: () => void): PathSubscription;
registerConsumerPaths(id: ConsumerId, paths: PathSet): void;
unregisterConsumer(id: ConsumerId): void;
protected emit(next: S): void;
protected patch(partial: DeepPartial<S>): void;
protected update(fn: (state: S) => S): void;
}
Base class for a state container. Abstract: extend it and expose methods that call the protected mutators.
getInternerFor(ctor)returns the per-class interner, creating it on first use.internerresolves it forthis.constructor.stateis the current state.channelis the underlyingDirtyChannel<PathSet>.consumerCountcounts consumers registered throughregisterConsumerPaths.getConsumerPaths()returns a copy of the registered consumers’ path sets.dispose()disposes the channel; later marks are ignored.subscribeis a pass-through tochannel.subscribe. Returns an unsubscribe function.subscribePathsreturns aPathSubscription. Its interest starts empty.registerConsumerPathsstores a snapshot ofpathsforidand updates the observed skeleton. Re-registering an equal set is a no-op.unregisterConsumerremovesid; unknown ids are ignored.emit(next): replace state. No-op whenObject.is(state, next). Diffs along the skeleton, or marksALL_PATHSwhen no consumer is registered or one registeredALL_PATHS.patch(partial): deep-merge plain objects, replace everything else. No-op when nothing changes. Marks changed paths only.update(fn):emit(fn(state)).
Throws: nothing of its own. Subscriber errors go to onError, or are re-thrown in the scheduler callback.
import { SyncScheduler } from '@dirtytalk/engine';
import { StructuralContainer } from '@dirtytalk/structural';
class Counter extends StructuralContainer<{ count: number }> {
constructor() {
super({ count: 0 }, { scheduler: new SyncScheduler() });
}
increment() {
this.update((s) => ({ ...s, count: s.count + 1 }));
}
}
StructuralContainerOptions
interface StructuralContainerOptions {
scheduler?: Scheduler;
equality?: ReadonlyMap<string, (a: unknown, b: unknown) => boolean>;
onError?: (err: unknown) => void;
}
scheduler: scheduler for the channel. Default is a sharedMicrotaskScheduler.equality: custom equality keyed by exact dotted path. Unlisted paths useObject.is.onError: receives errors thrown by subscribers or interest thunks during a flush.
import { SyncScheduler } from '@dirtytalk/engine';
import type { StructuralContainerOptions } from '@dirtytalk/structural';
const options: StructuralContainerOptions = {
scheduler: new SyncScheduler(),
equality: new Map([
['price', (a, b) => Math.abs(Number(a) - Number(b)) < 0.01],
]),
onError: (err) => console.error(err),
};
DeepPartial
type DeepPartial<T> =
T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends Date | Map<unknown, unknown> | Set<unknown> | RegExp
? T
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
Argument type of patch. Plain-object branches are optional recursively; arrays and built-in value types are taken whole.
import type { DeepPartial } from '@dirtytalk/structural';
type State = { user: { name: string; age: number } };
const patch: DeepPartial<State> = { user: { name: 'Ada' } };
PathSubscription
interface PathSubscription {
setInterest(interest: PathSet): void;
unsubscribe(): void;
}
Returned by subscribePaths. setInterest replaces the watched paths (do not mutate the set afterwards; passing the same set is a no-op). unsubscribe is idempotent.
import type { PathSubscription } from '@dirtytalk/structural';
import { ALL_PATHS } from '@dirtytalk/structural';
declare const sub: PathSubscription;
sub.setInterest(ALL_PATHS);
sub.unsubscribe();
Paths
PathId
type PathId = number;
Interned identifier for a path. Stable per container class.
ConsumerId
type ConsumerId = string | symbol;
Opaque consumer identifier used by registerConsumerPaths.
PathInterner
class PathInterner {
intern(path: string): PathId;
internChild(parentId: PathId, key: string): PathId;
childIds(parentId: PathId): ReadonlyMap<string, PathId>;
internAncestor(path: string): PathId;
internAncestorOf(realId: PathId): PathId;
ancestorWatchIds(id: PathId): readonly PathId[];
ancestorTargetId(id: PathId): PathId | undefined;
lookupSegments(id: PathId): readonly string[];
rootId(): PathId;
isRootId(id: PathId): boolean;
lookup(id: PathId): string;
isAncestorId(id: PathId): boolean;
get size(): number;
}
Append-only mapping between dotted path strings and PathIds.
intern(path): id for a dotted path ('user.name','items.0.title'); the same string always returns the same id.internChild(parentId, key):internofparent.keywithout building the string. Use-1asparentIdfor a top-level key.childIds(parentId): live map of child ids interned so far.internAncestor(path)/internAncestorOf(realId): the ancestor-watch id of a path.ancestorWatchIds(id): ancestor-watch ids of every non-root ancestor ofid, longest first.ancestorTargetId(id): the real path id behind an ancestor-watch id, elseundefined.lookupSegments(id): memoized path segments ([]for the root).rootId()/isRootId(id): the reserved root id, which decodes to''.lookup(id): path string for an id. Ancestor-watch ids decode to their real path, the root id to''.isAncestorId(id): true for ancestor-watch ids.size: number of interned entries, including ancestor-watch ids and sentinels.
Throws: lookup throws RangeError for an unknown id. A dev-mode warning is logged at 5000 entries.
import { PathInterner } from '@dirtytalk/structural';
const interner = new PathInterner();
const id = interner.intern('user.name');
interner.lookup(id); // 'user.name'
interner.internChild(interner.intern('user'), 'email') ===
interner.intern('user.email'); // true
PathSet
type PathSet = Set<PathId> | AllPaths;
A set of path ids, or ALL_PATHS. The dirty region type of a container’s channel.
import type { PathSet } from '@dirtytalk/structural';
const paths: PathSet = new Set([0, 1]);
AllPaths
type AllPaths = typeof ALL_PATHS;
Type of the ALL_PATHS sentinel.
ALL_PATHS
const ALL_PATHS: unique symbol; // Symbol.for('@dirtytalk/structural/ALL_PATHS')
Sentinel that intersects every non-empty path set. As an interest it means “wake on any change”. As a dirty set it means “everything may have changed”.
import { ALL_PATHS, PathSetSpace } from '@dirtytalk/structural';
PathSetSpace.intersects(ALL_PATHS, new Set([1])); // true
emptyPathSet
function emptyPathSet(): PathSet;
Returns a new empty Set<PathId>.
import { emptyPathSet } from '@dirtytalk/structural';
const none = emptyPathSet();
pathSetUnion
function pathSetUnion(a: PathSet, b: PathSet): PathSet;
Union of two path sets. Returns ALL_PATHS if either is. May return one input by reference when the other is empty; otherwise a new set.
import { pathSetUnion } from '@dirtytalk/structural';
pathSetUnion(new Set([1]), new Set([2])); // Set { 1, 2 }
pathSetEquals
function pathSetEquals(a: PathSet, b: PathSet): boolean;
True when both are ALL_PATHS, or both are sets with the same members.
import { ALL_PATHS, pathSetEquals } from '@dirtytalk/structural';
pathSetEquals(new Set([1]), new Set([1])); // true
pathSetEquals(ALL_PATHS, new Set([1])); // false
PathSetSpace
const PathSetSpace: Space<PathSet>; // Space is exported by @dirtytalk/engine
The engine Space implementation for path sets: empty, isEmpty, union, unionInto (in-place accumulate, never mutates its second argument), and intersects. Used by the container’s DirtyChannel.
import { PathSetSpace } from '@dirtytalk/structural';
PathSetSpace.intersects(new Set([1, 2]), new Set([2, 3])); // true
expandWithAncestors
function expandWithAncestors(paths: PathSet, interner: PathInterner): PathSet;
Adds an ancestor-watch id for every ancestor of every path in paths. Returns ALL_PATHS unchanged, and returns paths itself when no path has an ancestor. Does not mutate paths.
import { PathInterner, expandWithAncestors } from '@dirtytalk/structural';
const interner = new PathInterner();
const expanded = expandWithAncestors(
new Set([interner.intern('a.b.c')]),
interner,
);
// contains 'a.b.c' plus ancestor-watch ids for 'a.b' and 'a'
Tracking
trackRender
function trackRender<S>(
state: S,
interner: PathInterner,
proxyCache?: ProxyCache,
): TrackResult<S>;
Wraps state in a recording proxy. Parameters: state is the value to read; interner interns recorded paths (use the container’s); proxyCache optionally reuses proxies across calls. Returns a TrackResult. A non-object state is returned as-is with an empty path set. In development, writes through the proxy throw Error.
import { ALL_PATHS, PathInterner, trackRender } from '@dirtytalk/structural';
const interner = new PathInterner();
const { value, paths, disarm } = trackRender({ a: { b: 1 } }, interner);
value.a.b;
disarm();
if (paths !== ALL_PATHS) [...paths].map((id) => interner.lookup(id)); // ['a.b']
TrackResult
interface TrackResult<S> {
value: S;
paths: PathSet;
disarm: () => void;
}
Result of trackRender. paths is always a Set<PathId> at runtime, but is typed PathSet. disarm stops recording; later reads on the proxy tree pass through.
import type { TrackResult } from '@dirtytalk/structural';
declare const result: TrackResult<{ n: number }>;
result.disarm();
raw
function raw<T>(v: T): T;
Returns the underlying object of a recording proxy; returns v unchanged for anything else, including primitives, null and undefined.
import { raw } from '@dirtytalk/structural';
declare const tracked: { id: number };
const plain = raw(tracked);
untracked
const untracked: typeof raw;
Alias of raw. Use at component boundaries to pass an untracked snapshot.
import { untracked } from '@dirtytalk/structural';
declare const item: { title: string };
const snapshot = untracked(item);
ProxyCache
class ProxyCache {}
Caller-owned cache for reusing proxies across trackRender calls, keyed by (target, prefix). Create one per call site. Takes no constructor arguments.
import { ProxyCache, trackRender, PathInterner } from '@dirtytalk/structural';
const cache = new ProxyCache();
const interner = new PathInterner();
const first = trackRender({ list: [1] }, interner, cache);
first.disarm();
TrackTraceEvent
interface TrackTraceEvent {
instance: number;
kind:
| 'root-wrap'
| 'record'
| 'drop-parent'
| 'pin'
| 'ownKeys'
| 'has'
| 'pass-through'
| 'disarm';
prefix: string;
key?: string;
path?: string;
armed: boolean;
}
Event passed to the hook installed with __setTrackTrace. Internal and test-only.
import type { TrackTraceEvent } from '@dirtytalk/structural';
const log = (e: TrackTraceEvent) => console.log(e.kind, e.path);
__setTrackTrace
function __setTrackTrace(hook: ((e: TrackTraceEvent) => void) | null): void;
Installs (or clears with null) a global hook for every recording decision. Internal, test-only.
import { __setTrackTrace } from '@dirtytalk/structural';
__setTrackTrace((e) => console.log(e.kind));
__setTrackTrace(null);
__setPersistTrackingProxies
function __setPersistTrackingProxies(v: boolean | null): void;
Forces whether trackRender honors a ProxyCache (null restores the default, enabled). Internal, test-only.
import { __setPersistTrackingProxies } from '@dirtytalk/structural';
__setPersistTrackingProxies(false);
__setPersistTrackingProxies(null);
Diffing
diffAlongSkeleton
function diffAlongSkeleton<S>(
prev: S,
next: S,
skeleton: PathSet,
interner: PathInterner,
equalsAt?: (pathId: PathId, prev: unknown, next: unknown) => boolean,
): PathSet;
Returns the ids in skeleton whose values differ between prev and next. equalsAt overrides equality per path (default Object.is). An ALL_PATHS skeleton returns ALL_PATHS; an empty one returns an empty set. Pure.
import { PathInterner, diffAlongSkeleton } from '@dirtytalk/structural';
const interner = new PathInterner();
const skeleton = new Set([interner.intern('a'), interner.intern('b')]);
diffAlongSkeleton({ a: 1, b: 1 }, { a: 2, b: 1 }, skeleton, interner); // Set { id of 'a' }
changedPathsFromPatch
function changedPathsFromPatch<S>(
prev: S,
next: S,
patch: Partial<S>,
interner: PathInterner,
equalsAt?: (pathId: PathId, prev: unknown, next: unknown) => boolean,
): PathSet;
Walks the plain-object branches of patch and returns the paths whose values changed between prev and next. Each changed branch also contributes its own path. For a path replaced atomically (an array, null, a class instance) it also adds the path’s ancestor-watch id. Unchanged branches are pruned. Returns a new Set.
import { PathInterner, changedPathsFromPatch } from '@dirtytalk/structural';
const interner = new PathInterner();
const prev = { user: { name: 'Ada', age: 36 } };
const next = { user: { name: 'Grace', age: 36 } };
changedPathsFromPatch(prev, next, { user: next.user }, interner);
// Set { id of 'user', id of 'user.name' }
getAt
function getAt(state: unknown, path: string): unknown;
Reads the value at a dotted path ('user.email', 'items.5.name'). Returns undefined for any missing intermediate and never throws. An empty path returns state.
import { getAt } from '@dirtytalk/structural';
getAt({ items: [{ name: 'x' }] }, 'items.0.name'); // 'x'
React
useStructural
function useStructural<S, C extends StructuralContainer<S>>(
container: C,
options?: UseStructuralOptions,
): readonly [S, C];
Imported from @dirtytalk/structural/react. Subscribes the component to container for the paths read during render and returns [state, container], where state is a recording proxy. Requires React 18 or newer. See React.
import { StructuralContainer } from '@dirtytalk/structural';
import { useStructural } from '@dirtytalk/structural/react';
class Counter extends StructuralContainer<{ count: number }> {
constructor() {
super({ count: 0 });
}
increment() {
this.patch({ count: this.state.count + 1 });
}
}
const counter = new Counter();
export function CountButton() {
const [state, container] = useStructural(counter);
return <button onClick={() => container.increment()}>{state.count}</button>;
}
UseStructuralOptions
interface UseStructuralOptions {
select?: never;
}
Options for useStructural. There are no settable options today.
import type { UseStructuralOptions } from '@dirtytalk/structural/react';
const options: UseStructuralOptions = {};
UseStructuralResult
interface UseStructuralResult<S, C extends StructuralContainer<S>> {
0: S;
1: C;
readonly length: 2;
[Symbol.iterator](): IterableIterator<S | C>;
}
Tuple-like shape of the hook’s result. The hook itself is typed to return readonly [S, C].
import type { UseStructuralResult } from '@dirtytalk/structural/react';
import type { StructuralContainer } from '@dirtytalk/structural';
type Result = UseStructuralResult<
{ n: number },
StructuralContainer<{ n: number }>
>;