The lowest-level way to observe a bloc. Every container owns a channel — a DirtyChannel<PathSet> from the DirtyTalk engine — and channel.subscribe(interest, cb) registers a callback that fires when the channel flushes a dirty region that overlaps your declared interest.
Almost no application code needs this. It exists for plugins, devtools, and infrastructure that must compose path-scoped subscriptions directly on a container. If you are inside a React component, use useBloc; if you are outside React, use watch. watch is itself a thin wrapper around channel.subscribe(() => ALL_PATHS, ...).
channel, ALL_PATHS, and the PathSet type are public — @blac/core re-exports ALL_PATHS (value) and PathSet (type) specifically so plugins can compose channel subscriptions. The mechanism for minting a specificPathSet (the PathInterner and its numeric PathIds) is @internal: it is not re-exported from @blac/core and its representation can change between releases. In practice that means the only stable, portable interest you can construct from outside the framework is ALL_PATHS (observe everything). Path-scoped interests are an internal optimization that useBloc’s render tracker builds for itself.
A thunk returning the region this subscriber cares about. Re-evaluated lazily, once per flush per subscriber — not snapshotted at subscribe time. Skipped entirely on empty-dirty flushes. Return ALL_PATHS to be woken by every change.
cb
(dirty: PathSet) => void
Invoked with the accumulated dirty region whenever it intersects interest(). The bloc’s new state is already applied when this runs — read container.state inside the callback.
returns
() => void
An idempotent unsubscribe closure. Safe to call more than once, and safe to call from inside the callback mid-flush.
the ALL_PATHS sentinel — “every path”, i.e. wake on any change; or
a Set<PathId> of interned path ids — a specific set of fields.
PathIds are interned per container class by an internal PathInterner; you do not construct them by hand, and the interner is not part of the public @blac/core surface. Because of that, the portable interest from application/plugin code is ALL_PATHS. The Set<PathId> form is what useBloc’s render tracker assembles internally to get fine-grained re-renders — see Tracking.
Coalesced, asynchronous. The channel uses a microtask scheduler by default, so several synchronous emit/patch/update calls fold into a single flush. Your callback does not fire once per mutation; it fires once per flush, after the current tick. See System Events for the batching model.
Interest is a thunk, re-run each flush. The channel calls interest() lazily on every non-empty flush, then delivers only if intersects(interest(), dirty) is true. It is not captured once at subscribe time.
No immediate fire. Unlike watch, channel.subscribe does not invoke the callback once on setup. It only runs on the next flush after a matching change. If you need the current value immediately, read container.state yourself.
Errors are collected, not swallowed silently. A throw from your callback (or your interest thunk) is recorded; after all subscribers run, a single error re-throws as-is, and multiple throw as an AggregateError.
subscribe returns an unsubscribe function. Calling it marks the subscriber dead and removes it from the channel. The closure is idempotent — calling it twice is a no-op — and is safe to call from inside the callback (e.g. for one-shot subscriptions). Forgetting to call it leaks the subscription for the life of the bloc.
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.
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.
The bloc instance, fires once immediately, multi-bloc, watch.STOP.
Building a plugin / devtools / infra
channel.subscribe
Raw PathSet deltas, no instance sugar, no immediate fire.
In short: inside React → useBloc; outside React → watch for almost everything, and channel.subscribe only when you are composing on the channel itself. watch exists precisely so you rarely touch the channel directly — it is channel.subscribe(() => ALL_PATHS, ...) plus instance resolution, an immediate fire, and a STOP sentinel.