Containers
StructuralContainer holds immutable state, marks changed paths on a DirtyChannel, and wakes only consumers that read them.
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)
| Option | Type | Description |
|---|---|---|
scheduler | Scheduler | Scheduler for the underlying channel. Default: one shared MicrotaskScheduler, which drains every container in a single microtask. Pass SyncScheduler in tests and SSR. |
equality | ReadonlyMap<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) => void | Called 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: Sis the current state. Treat it as read-only.channel: DirtyChannel<PathSet>is the underlying dirty channel.interner: PathInterneris the interner shared by every instance of the same class (see Interner scope).consumerCount: numberis the number of consumers registered withregisterConsumerPaths.getConsumerPaths(): ReadonlyMap<ConsumerId, PathSet>returns a snapshot of each registered consumer’s paths, for devtools.ALL_PATHSmeans 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
emitdiffsprevagainstnextalong the observed skeleton and marks the paths whose values differ (perequalityoverrides, elseObject.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.lengthortodos.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
| Need | Use |
|---|---|
| Set known fields, possibly nested | patch |
| Replace the whole state or derive from it | emit |
| Compute next state from current | update |
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:
subscribePathsdoes not register paths with the skeleton. Without a registered consumer, every mutation marksALL_PATHS, which wakes every subscription that has a non-empty interest. CallregisterConsumerPathswith 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,emitorupdate. - Read from
this.stateinside methods; it always holds the latest state. - Handlers handed to components are plain methods:
onClick={() => container.rename('Grace')}. Methods that rely onthisneed an arrow function orbindif 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
statein place. Mutations bypass change tracking silently; the nextemitmay findprev === nextalong a path and skip it. - In
updateandemit, copy every object along the changed path and reuse untouched subtrees by reference. Reference-equal subtrees are skipped by the diff. patchhandles 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.prototypeornullprototype) and arrays are tracked structurally.Map,Set,Dateand class instances are leaves: a change is detected as a reference change at their own path.