# Containers

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

Source: https://dirtytalk.42.dog/docs/structural/containers/

`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

```ts
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

```ts
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](/docs/engine/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](/docs/engine/dirty-channel/).
- `interner: PathInterner` is the interner shared by every instance of the same class (see [Interner scope](#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

```ts
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

```ts
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

```ts
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:

```ts
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`](/docs/structural/react/) calls these for you.

## Subscriptions

### subscribe

```ts
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

```ts
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.

```ts
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":

```ts
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

```ts
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](/docs/structural/tracking/#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.
