Containers

StructuralContainer holds immutable state, marks changed paths on a DirtyChannel, and wakes only consumers that read them.

View .md Edit on GitHub

StructuralContainer<S> is the abstract base class. It owns the state, a DirtyChannel<PathSet>, and a registry of consumers. Subclasses expose methods that mutate state through the protected emit, patch and update.

Defining a container

import { SyncScheduler } from '@dirtytalk/engine';
import { StructuralContainer } from '@dirtytalk/structural';

interface TodoState {
  user: { name: string; email: string };
  todos: { id: number; title: string; done: boolean }[];
}

class Todos extends StructuralContainer<TodoState> {
  constructor() {
    super(
      { user: { name: 'Ada', email: 'ada@example.com' }, todos: [] },
      { scheduler: new SyncScheduler() },
    );
  }

  rename(name: string) {
    this.patch({ user: { name } });
  }

  add(title: string) {
    this.update((s) => ({
      ...s,
      todos: [...s.todos, { id: s.todos.length, title, done: false }],
    }));
  }
}

Mutators are protected: callers use the methods a subclass chooses to expose.

Constructor and options

constructor(initial: S, options?: StructuralContainerOptions)
OptionTypeDescription
schedulerSchedulerScheduler for the underlying channel. Default: one shared MicrotaskScheduler, which drains every container in a single microtask. Pass SyncScheduler in tests and SSR.
equalityReadonlyMap<string, (a: unknown, b: unknown) => boolean>Per-path equality override. Keys are exact dotted path strings ('user.name'); no globs. Paths without an entry use Object.is.
onError(err: unknown) => voidCalled when a subscriber callback or interest thunk throws during a flush. Unset, errors are re-thrown inside the scheduler callback and abort the rest of that flush.

See schedulers for the available scheduler implementations.

Reading state

  • state: S is the current state. Treat it as read-only.
  • channel: DirtyChannel<PathSet> is the underlying dirty channel.
  • interner: PathInterner is the interner shared by every instance of the same class (see Interner scope).
  • consumerCount: number is the number of consumers registered with registerConsumerPaths.
  • getConsumerPaths(): ReadonlyMap<ConsumerId, PathSet> returns a snapshot of each registered consumer’s paths, for devtools. ALL_PATHS means the consumer wakes on any change.
  • dispose() disposes the channel.

Mutators

All three are protected. All replace state before marking, so consumers see the new state when woken. Each marks only paths whose values changed.

emit

protected emit(next: S): void

Replaces the state with next.

  • If Object.is(state, next), nothing happens.
  • With no registered consumers, or any consumer registered with ALL_PATHS, the whole space is marked (ALL_PATHS).
  • Otherwise emit diffs prev against next along the observed skeleton and marks the paths whose values differ (per equality overrides, else Object.is). Subtrees that keep their reference are skipped.

patch

protected patch(partial: DeepPartial<S>): void

Deep-merges partial into the state. Plain-object branches merge recursively; arrays, class instances, Date, Map, Set, primitives, null and undefined replace the slot atomically.

  • If the merge changes nothing, the state is untouched and nothing is marked.
  • With no registered consumers, the whole space is marked.
  • Otherwise the marked paths come from the patch’s shape, filtered by value: a path is marked only if its value changed. A changed plain-object branch also marks its own path, so a consumer that read the whole branch wakes.
  • When an array or other atomic value is replaced, consumers that read a descendant (for example todos.length or todos.0.title) wake only if that specific descendant changed.

update

protected update(fn: (state: S) => S): void

Calls fn with the current state and passes the result to emit. Use it for changes that are awkward to express as a patch (array insert, remove, reorder).

Choosing a mutator

NeedUse
Set known fields, possibly nestedpatch
Replace the whole state or derive from itemit
Compute next state from currentupdate

patch does not delete keys: undefined is stored as the value. Use emit or update to remove a key.

Observed skeleton

The skeleton is the union of every path any registered consumer watches, kept as a refcounted trie. A path joins the skeleton when its first consumer registers and leaves when its last one unregisters. emit walks only this trie, so cost scales with what consumers read, not with the size of the state.

Consumers register with:

registerConsumerPaths(id: ConsumerId, paths: PathSet): void
unregisterConsumer(id: ConsumerId): void

registerConsumerPaths stores a snapshot of paths; re-registering an equal set is a no-op. useStructural calls these for you.

Subscriptions

subscribe

subscribe(interest: () => PathSet, cb: (dirty: PathSet) => void): () => void

Pass-through to channel.subscribe. The thunk is evaluated at flush time. Returns an unsubscribe function. Use it for devtools and manual subscribers that do not go through the tracker.

subscribePaths

subscribePaths(cb: () => void): PathSubscription

interface PathSubscription {
  setInterest(interest: PathSet): void;
  unsubscribe(): void;
}

Subscribes with an interest set through setInterest instead of a thunk. Subscriptions are indexed by path id, so a flush looks up only the dirty ids and a change costs the consumers it wakes, not one check per subscriber. A new subscription has empty interest and is never woken until setInterest is called.

setInterest replaces the watched set; the set must not be mutated afterwards. Passing the same set again is a no-op.

import { SyncScheduler } from '@dirtytalk/engine';
import { StructuralContainer } from '@dirtytalk/structural';

class Profile extends StructuralContainer<{ name: string; age: number }> {
  constructor() {
    super({ name: 'Ada', age: 36 }, { scheduler: new SyncScheduler() });
  }
  setAge(age: number) {
    this.patch({ age });
  }
}

const profile = new Profile();
const nameId = profile.interner.intern('name');

const sub = profile.subscribePaths(() => console.log('name changed'));
sub.setInterest(new Set([nameId]));
profile.registerConsumerPaths('example', new Set([nameId]));

profile.setAge(37); // does not fire
sub.unsubscribe();
profile.unregisterConsumer('example');

Note: subscribePaths does not register paths with the skeleton. Without a registered consumer, every mutation marks ALL_PATHS, which wakes every subscription that has a non-empty interest. Call registerConsumerPaths with the same paths to get precise wake-ups, as above.

ALL_PATHS

ALL_PATHS is a sentinel PathSet that intersects every non-empty set. The container marks it when no consumer is registered or a consumer registered with ALL_PATHS. As an interest it means “wake on any change”:

import { ALL_PATHS } from '@dirtytalk/structural';

container.subscribe(
  () => ALL_PATHS,
  () => {},
);

A consumer registered with ALL_PATHS forces emit to skip diffing and mark everything, so avoid it when precision matters.

Besides real path ids, a dirty set may contain a reserved root id, marked whenever a change lands outside every consumer’s skeleton and whenever a consumer has enumerated the root keys (Object.keys(state)). It exists so ALL_PATHS subscribers still wake when the diff is empty.

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;

Nested object branches accept partial shapes. Array values in a patch replace the array; they are not merged per index. Date, Map, Set and RegExp stay whole.

Subclassing and exposing methods

  • Call super(initial, options) from your constructor.
  • Put mutation logic in public methods that call patch, emit or update.
  • Read from this.state inside methods; it always holds the latest state.
  • Handlers handed to components are plain methods: onClick={() => container.rename('Grace')}. Methods that rely on this need an arrow function or bind if passed unbound.

Interner scope

The PathInterner is keyed by constructor (StructuralContainer.getInternerFor(ctor)). All instances of one class share path ids; a subclass of a subclass gets its own. Interners only grow, so avoid state shapes with unbounded dynamic keys (a dev-mode warning fires at 5000 interned entries).

Immutability rules

  • Never mutate state in place. Mutations bypass change tracking silently; the next emit may find prev === next along a path and skip it.
  • In update and emit, copy every object along the changed path and reuse untouched subtrees by reference. Reference-equal subtrees are skipped by the diff.
  • patch handles the copying for plain objects. Arrays and class instances inside a patch replace the old value.
  • Through a tracked proxy (inside a component render), writes throw in development; see tracking hazards.
  • Only plain objects (Object.prototype or null prototype) and arrays are tracked structurally. Map, Set, Date and class instances are leaves: a change is detected as a reference change at their own path.