API reference

Every public export of @dirtytalk/structural and @dirtytalk/structural/react with TypeScript signatures, parameters, returns and examples.

View .md Edit on GitHub

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. interner resolves it for this.constructor.
  • state is the current state. channel is the underlying DirtyChannel<PathSet>.
  • consumerCount counts consumers registered through registerConsumerPaths.
  • getConsumerPaths() returns a copy of the registered consumers’ path sets.
  • dispose() disposes the channel; later marks are ignored.
  • subscribe is a pass-through to channel.subscribe. Returns an unsubscribe function.
  • subscribePaths returns a PathSubscription. Its interest starts empty.
  • registerConsumerPaths stores a snapshot of paths for id and updates the observed skeleton. Re-registering an equal set is a no-op.
  • unregisterConsumer removes id; unknown ids are ignored.
  • emit(next): replace state. No-op when Object.is(state, next). Diffs along the skeleton, or marks ALL_PATHS when no consumer is registered or one registered ALL_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 shared MicrotaskScheduler.
  • equality: custom equality keyed by exact dotted path. Unlisted paths use Object.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): intern of parent.key without building the string. Use -1 as parentId for 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 of id, longest first.
  • ancestorTargetId(id): the real path id behind an ancestor-watch id, else undefined.
  • 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 }>
>;