This chapter rebuilds BlaC’s reactivity engine from the ground up, one stage at a
time. By the end you will be able to point at every moving part — the dirty
channel, the path interner, the recording proxy, the observed skeleton, and the
cross-bloc dependency layer — and say exactly what it does and why it has to.
If you only want the rules for writing components, read
Tracking and Dependency Tracking.
This page is for when you want the machine. It is grounded directly in the
source: the engine lives in @dirtytalk/engine, the path machinery in
@dirtytalk/structural, and BlaC’s lifecycle layer in @blac/core’s
StateContainer. The companion design narrative is the
Mental Model; the distilled standalone version is
@dirtytalk/structural Concepts.
We build in four stages, each strictly on top of the last:
Stage 1 state + listeners "something changed — wake everyone"
│ (DirtyChannel, coalesced flush)
▼
Stage 2 paths "what changed — as interned integers"
│ (PathInterner, PathSet, intersection)
▼
Stage 3 the skeleton "who reads what — diff once, fan out cheap"
│ (trackRender proxy + observed skeleton)
▼
Stage 4 cross-bloc deps "one bloc leans on another"
Start with the smallest honest model: a value, a set of listeners, and a way to
say “I changed.” Every reactive store has this core. The naive version notifies
every listener synchronously on every change.
BlaC’s real notification core is DirtyChannel<Region> from @dirtytalk/engine,
but we approach it from the outside in. From the outside, that contract is what
Cubit exposes: a value behind
state, mutated by emit / update, with watch (or the lower-level
channel.subscribe) firing on every coalesced flush. This block type-checks
against the real @blac/core surface:
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<{
count: number
count:number }> {
constructor() {
super({
count: number
count: 0 });
}
CounterCubit.increment(): void
increment() {
// Immutable replacement. Several synchronous emits coalesce into ONE flush.
Ensure an instance exists without taking ownership (no ref added). Instance
identity is derived purely from args, matching acquire.
ensure(
classCounterCubit
CounterCubit);
const c:CounterCubit
c.
CounterCubit.increment(): void
increment();
const c:CounterCubit
c.
CounterCubit.increment(): void
increment(); // two emits, same tick → callback fires once with count: 2
const stop:() => void
stop();
Underneath, watch is a thin bridge over the real notification core:
DirtyChannel<Region> from @dirtytalk/engine. It is generic over a Region —
an opaque “what changed” value governed by a Space<Region> algebra
(empty, isEmpty, union, intersects). Two properties make it more than a
plain EventEmitter. Modeled with a trivial boolean Region where “dirty” is
just true (illustrative — @dirtytalk/engine is the engine package, not a
docs dependency, so this block is shown rather than type-checked):
import { DirtyChannel, SyncScheduler, type Space } from'@dirtytalk/engine';
// The simplest possible Region: a boolean. `true` = "something changed".
const BoolSpace:Space<boolean> = {
empty: () => false,
isEmpty: (r) => r === false,
union: (a, b) => a || b,
// Any non-empty dirty wakes any interested subscriber.
Marks accumulate, flushes coalesce.mark(region) unions the region into
an accumulator and asks the scheduler to flush once. Several synchronous
marks collapse into a single flush — so three emits in one tick wake each
listener at most once.
Interests are thunks evaluated at flush time. A subscriber registers
subscribe(() => interest, cb). On flush the channel evaluates the interest
thunk, tests space.intersects(interest, dirty), and calls cb only on a
hit. (This is the seam Stage 2 and 3 exploit; here the region is just
“everything.”)
Stage 1: mark → flush → wake everyone
increment() increment() (same tick)
│ │
▼ ▼
mark(true) mark(true) union → still `true`
└─────┬──────┘
▼
scheduler.request(flush) coalesced: scheduled once
│
▼
flush: dirty = true ───────────► every subscriber whose
interest intersects → cb()
This is everything a Cubit does at the listener level. The
watch(BlocClass, cb) helper is exactly this: it bridges every channel
flush to a callback that wakes on any change, regardless of what changed,
applied from outside React. The flush is microtask-coalesced, which is why
several synchronous emits produce a single notification.
What’s missing: “wake everyone” is wasteful. A container with twenty fields
wakes a listener that reads one of them on all twenty kinds of change. Stage 2
makes “what changed” precise.
Replace the boolean region with a set of changed paths. A path is a dotted
route to a leaf in the state tree: user.name, items, user.address.city. A
mutation marks the paths it touched; a listener declares the paths it reads; the
channel wakes a listener only when those two sets intersect.
Comparing and unioning dotted strings on every mutation would be wasteful, so
every path is interned into a small integer PathId once. PathInterner
(in @dirtytalk/structural, used internally by every BlaC container) is a
bidirectional string ↔ PathId table: intern(path) returns a stable id (the
next sequential integer, starting at 0), idempotently; lookup(id) reverses
it.
interner.intern('user.name'); // 0 again — idempotent
interner.lookup(0); // 'user.name'
interner.size; // 2
Ids are only comparable inside one interner’s namespace, so BlaC keeps one
interner per container subclass, keyed by constructor in a WeakMap
(StructuralContainer.getInternerFor). Every instance of one Cubit subclass
shares an interner, so their ids line up and can be unioned/intersected freely;
distinct subclasses get distinct interners even if their state shapes match. The
WeakMap lets the interner be collected once the class is gone.
A PathSet is the unit of both “interest” and “dirtiness.” It is either a
concrete Set<PathId> or the ALL_PATHS sentinel ("every possible path").
The set algebra is a Space<PathSet> — PathSetSpace — the same Space
contract from Stage 1, now over path sets instead of booleans:
import {
PathInterner,
PathSetSpace,
ALL_PATHS,
type PathSet,
} from'@dirtytalk/structural';
const interner = newPathInterner();
const name = interner.intern('user.name'); // 0
const email = interner.intern('user.email'); // 1
// A listener that reads user.name; a mutation that changed user.email.
ALL_PATHS and the PathSet type are the only path primitives BlaC re-exports
from @blac/core (for plugins composing channel subscriptions); this
type-checked block uses them directly:
import {
const ALL_PATHS:typeofALL_PATHS
ALL_PATHS, type
type PathSet =typeofALL_PATHS|Set<number>
PathSet } from'@blac/core';
// A plugin or devtools panel can declare blanket interest in every change.
const
const blanketInterest:() => PathSet
blanketInterest:() =>
type PathSet =typeofALL_PATHS|Set<number>
PathSet = () =>
const ALL_PATHS:typeofALL_PATHS
ALL_PATHS;
void
const blanketInterest:() => PathSet
blanketInterest;
The channel is identical to Stage 1; only the Region changed from boolean to
PathSet. intersects does the discrimination: iterate the smaller set, look
up in the larger, return on the first hit. That is the entire per-listener cost
of a change — no tree walk.
Where do the changed paths come from? The mutators compute them:
emit(next) installs a new immutable state, then diffs to find which
observed paths changed value (Stage 3 covers the skeleton bound on that diff).
patch(partial) deep-merges a DeepPartial<S> and marks paths via
changedPathsFromPatch: it walks the patch shape, pulses each touched branch
up ({ user: { email } } marks both user and user.email), but
value-filters — a path is marked only if its value actually changed, and
recursion prunes into unchanged branches (an unchanged subtree keeps its
reference). So an over-spread patch that re-sets a whole parent when one field
changed does not over-wake the siblings.
update(fn) is sugar for emit(fn(state)).
From the public surface, the three mutators look like this — and all type-check:
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<
interface UserState
UserState> {
constructor() {
super({
UserState.user: {
name: string;
email: string;
}
user: {
name: string
name: 'Ada',
email: string
email: 'ada@x.io' },
UserState.items: string[]
items: [] });
}
// patch: deep-merges; marks only paths whose value actually changed.
// Here only `user.email` moves → `user.name` consumers stay asleep.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so stateChanged
system events see the merged prev/next), and the registry-level
stateChanged notification. We still call
super.patch so path-marking semantics (the whole point of patch) are
preserved.
patch({
user?: {
name?: string |undefined;
email?: string |undefined;
} |undefined
user: {
email?: string |undefined
email } });
}
// update: sugar for emit(fn(state)); replace immutably.
What’s still missing: a listener must somehow declare its path set. Writing it
by hand is a selector — the exact thing BlaC avoids. Stage 3 makes the read
itself the declaration.
Stage 3 — The recording proxy and the observed skeleton
A consumer declares which paths it reads by reading them. trackRender(state, interner) wraps state in a recording Proxy and returns
{ value, paths }: value is the proxy you read during render, paths is a
liveSet<PathId> that grows as you touch properties off value.
trackRender is internal (@dirtytalk/structural); the React adapter calls it
for you on every render, so you never import it directly. Shown here to make the
mechanism concrete:
import { PathInterner, trackRender, type PathId } from'@dirtytalk/structural';
The recording rules are deliberate, and each one earns its keep:
Leaf-only (maximal) recording. Reading user.name records user.name and
drops the intermediate user from the set. So when an immutable update
replaces the whole user object because a sibling (user.email) changed, a
consumer that only read user.name does not wake — its recorded leaf
still resolves to the same value. Read the whole user object (no deeper key)
and user stays your leaf, so any change inside it wakes you.
Own, non-symbol reads only. Symbol keys (Symbol.iterator) and inherited
prototype props never record. Re-reads are idempotent (it’s a Set).
Nested plain objects/arrays return child proxies recording into the same
set, cached per target so value.user === value.user within one render.
Iteration coarsens..map / .find / for..of on an array record the
entry path (items) but not per-index paths (items.0) or items.length.
Array methods are bound to the raw target so their internal reads bypass the
proxy. Direct index access still records the leaf: value.items[2] records
items.2.
Leaf collection types are not recursed.Map, Set, Date, and class
instances are returned raw (wrapping them would rebind receiver-checked
built-ins like Map.prototype.get). A reference change to the whole value
still wakes you, but an in-place .set() does not.
Now the payoff. With N consumers sharing a container, the naive way to find
who to wake is per-consumer diffing: N tree walks. BlaC does one walk plus N
intersections instead.
The trick is the observed skeleton: the union of every registered consumer’s
path set. On a mutation, emit diffs prev vs nextonly along the
skeleton — it reads each skeleton path in both states and includes it iff the
value moved (diffAlongSkeleton, default Object.is). That one walk produces
the dirty set; then each consumer wakes via a cheap set intersection (Stage 2).
Again the helpers are internal — this is the mechanism emit runs for you:
import {
PathInterner,
diffAlongSkeleton,
pathSetUnion,
type PathSet,
} from'@dirtytalk/structural';
interface State {
user: { name:string; email:string };
}
const interner = newPathInterner();
// Two consumers: one reads user.name, the other reads user.email.
The registry maintaining the skeleton is in StructuralContainer:
registerConsumerPaths(id, paths) stores one consumer’s set and recomputes the
skeleton as the union of all of them (with a fast-path skip when the set is
unchanged); unregisterConsumer(id) drops one and recomputes.
useBloc (and the standalone useStructural) needs no selector because the
JSX is the interest declaration. Per render it:
derives a stable consumer id from useId() and forces re-renders with a
useReducer tick (no virtual DOM — React reconciles);
calls trackRender(container.state, container.interner), renders against the
proxy, and stashes the now-populated paths in a ref;
registers that set in a layout effect after render (never in the render
body — at that point paths is still empty and would freeze an empty
skeleton, silently dropping wakeups);
subscribes () => pathRef.current to the channel, so the channel re-evaluates
the live interest on every flush and re-renders only on intersection.
Conditional reads therefore reshape the skeleton automatically, render to
render. To opt out and gate re-renders by a derived value array instead, pass
select to useBloc.
The final layer: one bloc leaning on another. This lives in @blac/core’s
StateContainer (the class Cubit extends), not in the structural engine —
deps are about identity and lifecycle, not path diffing.
this.depend(OtherBloc) declares a dependency and returns a DepHandle.
Calling .untracked() or .track() on the handle resolves the dependency
against the registry, so the declaring bloc never holds a stale instance
reference across dep churn:
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<S> is a StateContainer<S> with emit / patch exposed as
public mutation surface. Today it adds nothing structurally beyond
StateContainer — both are inherited from the underlying
StructuralContainer<S>. Kept as a real class (not a type alias) because
downstream code does instance instanceof Cubit checks.
The class body is intentionally empty: a no-op emit override would
still go through applyState, and patch is inherited from
StructuralContainer (path-diffed, microtask-flushed). A caller that
wants "skip if no real change" patch semantics can wrap patch
themselves or call emit after a manual equality check.
Cubit<{
ready: boolean
ready:boolean }> {
// `depend` returns a handle resolved against the registry per call.
Declare a cross-bloc dependency. Returns a branded handle with two
accessors — the dep instance is resolved against the registry on each call,
which keeps the surface immune to dep-instance churn:
handle.track(options?) — reactive read. Returns [state, instance].
Inside a getter reached through the React proxy this subscribes the
reading component to the dep's changes (base impl: live, no subscription).
handle.untracked(options?) — returns the live instance with no tracking,
for imperative method calls and one-off reads.
defaultArgs resolves the dep instance when an accessor is called without
its own args; per-call options.args overrides it and can derive from
current state. This does NOT auto-resubscribe outside the React proxy;
non-React consumers needing updates should subscribe explicitly.
depend does not auto-resubscribe. It records the dependency (visible via
the dependencies getter) and returns a handle. It deliberately does not
bridge the dep’s channel into this bloc — a naive auto-bridge would cycle on
mutual deps. A consumer that needs reactive updates from a dep subscribes
explicitly, which in React means a component useBlocs both blocs and the
Stage-3 tracker handles each independently.
Per-consumer deps are a separate, owner-keyed mechanism. The
APPLY_DEPS / REMOVE_DEPS_OWNER symbols (internal, used by the React
adapter) let each consumer inject a slice of deps keyed by an owner id;
StateContainer shallow-merges all live owners’ slices, dev-warns on
cross-owner key collisions (last write wins), and fires onDepsChanged only
when the merged view actually changes. This is distinct from depend’s
bloc-to-bloc resolution.
Stage 4: deps resolve through the registry (no auto-bridge)
DashboardCubit registry
│ │
│ this.depend(AuthCubit) │
│ ── records dep, returns handle ──► │
│ │
│ auth.untracked() ─ ensure(AuthCubit)►│ ── returns the live
│ │ AuthCubit instance
│ ◄──────────── instance ──────────── │
▼
reads dep.state (reactivity, if wanted, is the consumer's job
via Stages 1–3 on each bloc independently)
StateContainer adds the rest of the lifecycle around these four stages:
identity ($blac.name, $blac.id), disposal, hydration, an
onSystemEvent('stateChanged' | 'dispose' | 'hydrationChanged', cb) surface,
and a dev-only emit-rate circuit breaker that warns once when a runaway loop
pushes too many changes through state in a second. But the reactivity itself is
the four stages above: a coalescing channel, interned paths, a recording proxy
feeding an observed skeleton, and a registry resolving cross-bloc deps.
A method calls emit / update / patch. StateContainer runs its guards
(disposed, equality short-circuit, emit-rate check), captures the
prev/next change, and delegates change-detection to
StructuralContainer. (Stage 1 + 4 wrapper.)
The container installs the new immutable state and computes the dirty
PathSet — diffAlongSkeleton against the observed skeleton for emit,
changedPathsFromPatch for patch — then channel.mark(dirty).
(Stage 2 + 3.)
The DirtyChannel coalesces marks and schedules one flush per tick. (Stage 1.)
On flush, each subscriber’s interest thunk is evaluated and intersected with
the dirty set; only consumers with a non-empty intersection have their
callback fired (React re-render, watch callback, plugin sink). (Stage 1 + 2.)
On the next render, the proxy re-records each consumer’s interest from
scratch, so conditional reads reshape the skeleton automatically. (Stage 3.)
The practical takeaway is the same sentence the whole machine exists to make
true, cheaply: read exactly the state you render, keep state immutable, and
BlaC re-renders the minimum.