Skip to content

channel.subscribe

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 specific PathSet (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.

// On the channel (DirtyChannel<PathSet>), from packages/dirtytalk-engine/src/dirty-channel.ts:58
subscribe(interest: () => Region, cb: (dirty: Region) => void): () => void

For a bloc’s channel, Region is instantiated as PathSet:

// container.channel is DirtyChannel<PathSet>, so subscribe reads as:
subscribe(interest: () => PathSet, cb: (dirty: PathSet) => void): () => void
ParameterTypeDescription
interest() => PathSetA 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) => voidInvoked 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() => voidAn idempotent unsubscribe closure. Safe to call more than once, and safe to call from inside the callback mid-flush.
packages/dirtytalk-structural/src/path-set.ts
const ALL_PATHS: unique symbol;
type AllPaths = typeof ALL_PATHS;
type PathSet = Set<PathId> | AllPaths;
// packages/dirtytalk-structural/src/types.ts
type PathId = number;

A PathSet is either:

  • 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.

import {
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

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
,
const ALL_PATHS: typeof ALL_PATHS
ALL_PATHS
} from '@blac/core';
import {
function ensure<T extends StateContainerConstructor>(BlocClass: T, opts?: {
args?: ExtractArgs<T>;
}): InstanceType<T>

Ensure an instance exists without taking ownership (no ref added). Instance identity is derived purely from args, matching acquire.

ensure
} from '@blac/core';
class
class CounterCubit
CounterCubit
extends
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

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
() {
this.
StateContainer<{ count: number; }, void, Record<string, never>>.patch(partial: {
count?: number | undefined;
}): void

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
({
count?: number | undefined
count
: this.
StructuralContainer<{ count: number; }>.state: {
count: number;
}
state
.
count: number
count
+ 1 });
}
}
const
const counter: CounterCubit
counter
=
ensure<typeof CounterCubit>(BlocClass: typeof CounterCubit, opts?: {
args?: void | undefined;
} | undefined): CounterCubit

Ensure an instance exists without taking ownership (no ref added). Instance identity is derived purely from args, matching acquire.

ensure
(
class CounterCubit
CounterCubit
);
// Subscribe to ALL changes on this bloc's channel.
const
const unsubscribe: () => void
unsubscribe
=
const counter: CounterCubit
counter
.
StructuralContainer<{ count: number; }>.channel: DirtyChannel<PathSet>
channel
.
DirtyChannel<PathSet>.subscribe(interest: () => PathSet, cb: (dirty: PathSet) => void): () => void
subscribe
(
() =>
const ALL_PATHS: typeof ALL_PATHS
ALL_PATHS
, // interest thunk: re-run each flush
(
dirty: PathSet
dirty
) => {
// `dirty` is the PathSet that changed; the new state is already applied.
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
('counter changed ->',
const counter: CounterCubit
counter
.
StructuralContainer<{ count: number; }>.state: {
count: number;
}
state
.
count: number
count
);
},
);
const counter: CounterCubit
counter
.
CounterCubit.increment(): void
increment
();
const counter: CounterCubit
counter
.
CounterCubit.increment(): void
increment
(); // both fold into ONE coalesced flush next microtask
// Later — tear down. Idempotent.
const unsubscribe: () => void
unsubscribe
();
const unsubscribe: () => void
unsubscribe
(); // safe no-op

channel.subscribe is the floor of a three-tier API. Pick the highest tier that fits:

Where you areUseWhat you get
Inside a React componentuseBlocAuto-tracked, path-scoped re-renders; lifecycle tied to mount.
Outside React (logging, sync, imperative UI, tests)watchThe bloc instance, fires once immediately, multi-bloc, watch.STOP.
Building a plugin / devtools / infrachannel.subscribeRaw 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.

  • watch — the outside-React wrapper around this method; prefer it unless you need raw channel access
  • Tracking — how useBloc builds a fine-grained Set<PathId> interest so it re-renders only on the fields you read
  • System Events — the microtask flush and coalescing model behind every channel callback
  • DirtyTalk engine: DirtyChannel — the underlying channel, its flush semantics, and the Space/Scheduler it is built on