Some behavior does not belong to any single bloc — logging every state change, mirroring state into DevTools, persisting to IndexedDB, sending analytics. Wiring that into each bloc by hand would scatter the same concern across your codebase and couple your domain logic to infrastructure.
A plugin is the escape hatch: it installs once, globally, and observes lifecycle events across every state container in the registry. Write the cross-cutting concern once; it applies everywhere automatically.
A plugin is a plain object: a name, a version, and any subset of the optional hooks below. Implement only what you need — every hook is optional.
import { type
(alias) interface BlacPlugin
import BlacPlugin
BlaC plugin hook surface.
State-change events carry the PathSet of paths that changed in the flush.
Lifecycle events (onCreated, onDestroyed) are not tied to a state change
and so do not carry paths.
Hook firing model:
onCreated fires synchronously when a container is first acquired.
onStateChange fires once per channel flush (microtask-coalesced).
prev is the state before the first emit of the flush; next is the
state at flush time; paths is the set of paths marked during the flush.
onDestroyed fires synchronously when a container is disposed (after
its system 'dispose' event).
The ref/deps hooks (onRefAcquired, onRefReleased, onDepsChanged) are
devtools-only — they drive devtools-connect and are orthogonal to the
state-change event payload.
BlacPlugin, type
type PathSet =Set<number> |typeofALL_PATHS
PathSet,
const ALL_PATHS:typeofALL_PATHS
ALL_PATHS } from'@blac/core';
const
const myPlugin:BlacPlugin
myPlugin:
(alias) interface BlacPlugin
import BlacPlugin
BlaC plugin hook surface.
State-change events carry the PathSet of paths that changed in the flush.
Lifecycle events (onCreated, onDestroyed) are not tied to a state change
and so do not carry paths.
Hook firing model:
onCreated fires synchronously when a container is first acquired.
onStateChange fires once per channel flush (microtask-coalesced).
prev is the state before the first emit of the flush; next is the
state at flush time; paths is the set of paths marked during the flush.
onDestroyed fires synchronously when a container is disposed (after
its system 'dispose' event).
The ref/deps hooks (onRefAcquired, onRefReleased, onDepsChanged) are
devtools-only — they drive devtools-connect and are orthogonal to the
state-change event payload.
BlacPlugin = {
BlacPlugin.name: string
name: 'my-plugin',
BlacPlugin.version: string
version: '1.0.0',
// Fires once when the plugin is installed.
// ctx.container is undefined here — onInstall is global, not per-bloc.
BlacPlugin.onInstall?(ctx:PluginContext):void
Fires once when the plugin is installed. ctx.container is undefined
here — onInstall is global to the plugin, not per-container.
onInstall(
ctx: PluginContext
ctx) {},
// Fires once when the plugin is uninstalled. No context.
BlacPlugin.onUninstall?():void
onUninstall() {},
// Fires when any state container is first created and acquired.
// The new container is ctx.container.
BlacPlugin.onCreated?(ctx:PluginContext):void
Fires when a container is first created and acquired.
ctx.container is the new container.
onCreated(
ctx: PluginContext
ctx) {},
// Fires once per microtask flush after any state change.
// prev/next are the coalesced before/after states; paths is the
// set of changed property paths (or ALL_PATHS for a full change).
Fires once per channel flush with the changed PathSet.
paths may be ALL_PATHS when the change spans every path.
prev/next are the coalesced before/after states for the flush.
onStateChange(
ctx: PluginContext
ctx,
prev: S extends object = any
prev,
next: S extends object = any
next,
paths: PathSet
paths:
type PathSet =Set<number> |typeofALL_PATHS
PathSet) {},
// Fires when any state container is disposed.
// The disposed container is ctx.container ($blac.disposed is already true).
BlacPlugin.onDestroyed?(ctx:PluginContext):void
Fires when a container is disposed.
ctx.container is the disposed container (still queryable, but its
isDisposed is true by the time this fires).
onDestroyed(
ctx: PluginContext
ctx) {},
// Fires on every hydration status transition of any container.
The interface also declares onRefAcquired(ctx, refId), onRefReleased(ctx, refId), and onDepsChanged(ctx, previousDeps, currentDeps). These are marked @internal and exist to support the DevTools connector; they are not part of the stable plugin contract and may change. Avoid them in application plugins.
Set false to register the plugin but skip all hook dispatch.
environment
'all'
Active only when process.env.NODE_ENV matches ('development', 'production', 'test') or 'all'. A mismatch logs a skip and installs nothing.
The environment gate is a runtime check against NODE_ENV, not tree-shaking. Use 'development' for debug-only plugins (logging, DevTools) so they add zero overhead in production; use 'all' for plugins you genuinely want everywhere, like persistence.
uninstall takes the plugin’s name string, not the plugin object. It throws if no plugin with that name is installed.
getPluginManager().uninstall('my-plugin');
Other PluginManager methods: getPlugin(name), getAllPlugins(), hasPlugin(name), clear() (uninstall all), and destroy() (clear plus detach the registry lifecycle hooks).
The ctx parameter (always first) carries the focal container plus safe, read-only access to registry data. A fresh ctx is built per dispatch — it is cheap and closes over the registry.
ctx.container — the bloc this event is about (StateContainer | undefined). It is undefined only inside onInstall. For per-container hooks (onCreated, onStateChange, onDestroyed, onHydrationChange) it is the focal bloc. Use ctx.container to read state, interner, $blac.id, etc.
The methods below take an instance argument so a plugin can also reach blocs other than the focal one (e.g. via queryInstances):
The five hydration methods — startHydration, applyHydratedState, finishHydration, failHydration, and waitForHydration — exist so a plugin can drive the hydration lifecycle from the outside. The Persistence plugin is their real-world consumer: it begins hydration, restores saved state, and finishes (or fails) hydration through exactly these methods.
onStateChange receives paths: PathSet as its fourth argument: a Set<PathId> of the property paths that changed in the flush — or the ALL_PATHS sentinel when the change spans every path (for example a full emit replacing the whole state, or a single-consumer container that skips path diffing). It is never undefined.
A PathId is an integer, not a string. Each BlaC class has its own per-class PathInterner that maps path strings ("items", "user.profile.name") to those integers. Interning to integers keeps the hot path (diffing and set intersection on every flush) cheap — comparing numbers beats comparing strings. To turn a PathId back into a readable string, call interner.lookup(pathId) on the focal container’s interner, reachable via ctx.container:
import { type
(alias) interface BlacPlugin
import BlacPlugin
BlaC plugin hook surface.
State-change events carry the PathSet of paths that changed in the flush.
Lifecycle events (onCreated, onDestroyed) are not tied to a state change
and so do not carry paths.
Hook firing model:
onCreated fires synchronously when a container is first acquired.
onStateChange fires once per channel flush (microtask-coalesced).
prev is the state before the first emit of the flush; next is the
state at flush time; paths is the set of paths marked during the flush.
onDestroyed fires synchronously when a container is disposed (after
its system 'dispose' event).
The ref/deps hooks (onRefAcquired, onRefReleased, onDepsChanged) are
devtools-only — they drive devtools-connect and are orthogonal to the
state-change event payload.
BlacPlugin, type
type PathSet =Set<number> |typeofALL_PATHS
PathSet,
const ALL_PATHS:typeofALL_PATHS
ALL_PATHS } from'@blac/core';
const
const pathLoggingPlugin:BlacPlugin
pathLoggingPlugin:
(alias) interface BlacPlugin
import BlacPlugin
BlaC plugin hook surface.
State-change events carry the PathSet of paths that changed in the flush.
Lifecycle events (onCreated, onDestroyed) are not tied to a state change
and so do not carry paths.
Hook firing model:
onCreated fires synchronously when a container is first acquired.
onStateChange fires once per channel flush (microtask-coalesced).
prev is the state before the first emit of the flush; next is the
state at flush time; paths is the set of paths marked during the flush.
onDestroyed fires synchronously when a container is disposed (after
its system 'dispose' event).
The ref/deps hooks (onRefAcquired, onRefReleased, onDepsChanged) are
devtools-only — they drive devtools-connect and are orthogonal to the
state-change event payload.
Fires once per channel flush with the changed PathSet.
paths may be ALL_PATHS when the change spans every path.
prev/next are the coalesced before/after states for the flush.
Reserved meta namespace: identity (name/id/debug/createdAt),
lifecycle (disposed/dependencies), and the hydration sub-surface.
Own, frozen, branded data property — buildTrackedProxy only intercepts
prototype getters, so this own property and its closure-based getters are
proxy-safe. See
createMeta
.
$blac.
BlacMeta<any>.name: string
name}] changed: (all paths)`);
return;
}
for (const
const pathId:number
pathIdof
paths: Set<number>
paths) {
const
const path:string
path =
const container:StateContainer<any, any, any>
container.
StructuralContainer<any>.interner: PathInterner
interner.
PathInterner.lookup(id: PathId): string
lookup(
const pathId:number
pathId);
var console:Console
console.
Console.log(...data: any[]): void
The console.log() static method outputs a message to the console.
Reserved meta namespace: identity (name/id/debug/createdAt),
lifecycle (disposed/dependencies), and the hydration sub-surface.
Own, frozen, branded data property — buildTrackedProxy only intercepts
prototype getters, so this own property and its closure-based getters are
proxy-safe. See
Here is an end-to-end plugin you can install as-is. It logs creation and disposal, and reports each state change with its changed paths — a self-contained template you can adapt for analytics, audit logs, or telemetry.
import {
functiongetPluginManager():PluginManager
Get the global plugin manager
getPluginManager,
type
(alias) interface BlacPlugin
import BlacPlugin
BlaC plugin hook surface.
State-change events carry the PathSet of paths that changed in the flush.
Lifecycle events (onCreated, onDestroyed) are not tied to a state change
and so do not carry paths.
Hook firing model:
onCreated fires synchronously when a container is first acquired.
onStateChange fires once per channel flush (microtask-coalesced).
prev is the state before the first emit of the flush; next is the
state at flush time; paths is the set of paths marked during the flush.
onDestroyed fires synchronously when a container is disposed (after
its system 'dispose' event).
The ref/deps hooks (onRefAcquired, onRefReleased, onDepsChanged) are
devtools-only — they drive devtools-connect and are orthogonal to the
state-change event payload.
BlacPlugin,
type
type PathSet =Set<number> |typeofALL_PATHS
PathSet,
const ALL_PATHS:typeofALL_PATHS
ALL_PATHS,
} from'@blac/core';
const
const auditPlugin:BlacPlugin
auditPlugin:
(alias) interface BlacPlugin
import BlacPlugin
BlaC plugin hook surface.
State-change events carry the PathSet of paths that changed in the flush.
Lifecycle events (onCreated, onDestroyed) are not tied to a state change
and so do not carry paths.
Hook firing model:
onCreated fires synchronously when a container is first acquired.
onStateChange fires once per channel flush (microtask-coalesced).
prev is the state before the first emit of the flush; next is the
state at flush time; paths is the set of paths marked during the flush.
onDestroyed fires synchronously when a container is disposed (after
its system 'dispose' event).
The ref/deps hooks (onRefAcquired, onRefReleased, onDepsChanged) are
devtools-only — they drive devtools-connect and are orthogonal to the
state-change event payload.
BlacPlugin = {
BlacPlugin.name: string
name: 'audit-log',
BlacPlugin.version: string
version: '1.0.0',
BlacPlugin.onCreated?(ctx:PluginContext):void
Fires when a container is first created and acquired.
ctx.container is the new container.
Reserved meta namespace: identity (name/id/debug/createdAt),
lifecycle (disposed/dependencies), and the hydration sub-surface.
Own, frozen, branded data property — buildTrackedProxy only intercepts
prototype getters, so this own property and its closure-based getters are
proxy-safe. See
Reserved meta namespace: identity (name/id/debug/createdAt),
lifecycle (disposed/dependencies), and the hydration sub-surface.
Own, frozen, branded data property — buildTrackedProxy only intercepts
prototype getters, so this own property and its closure-based getters are
proxy-safe. See
Fires once per channel flush with the changed PathSet.
paths may be ALL_PATHS when the change spans every path.
prev/next are the coalesced before/after states for the flush.
Calls a defined callback function on each element of an array, and returns an array that contains the results.
@param ― callbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.
@param ― thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
map((
id: number
id) =>
const c:StateContainer<any, any, any>
c.
StructuralContainer<any>.interner: PathInterner
interner.
PathInterner.lookup(id: PathId): string
lookup(
id: number
id));
var console:Console
console.
Console.log(...data: any[]): void
The console.log() static method outputs a message to the console.
Reserved meta namespace: identity (name/id/debug/createdAt),
lifecycle (disposed/dependencies), and the hydration sub-surface.
Own, frozen, branded data property — buildTrackedProxy only intercepts
prototype getters, so this own property and its closure-based getters are
proxy-safe. See
createMeta
.
$blac.
BlacMeta<any>.name: string
name} changed:`,
const changed:string[]
changed);
},
BlacPlugin.onDestroyed?(ctx:PluginContext):void
Fires when a container is disposed.
ctx.container is the disposed container (still queryable, but its
isDisposed is true by the time this fires).
Reserved meta namespace: identity (name/id/debug/createdAt),
lifecycle (disposed/dependencies), and the hydration sub-surface.
Own, frozen, branded data property — buildTrackedProxy only intercepts
prototype getters, so this own property and its closure-based getters are
proxy-safe. See
Reserved meta namespace: identity (name/id/debug/createdAt),
lifecycle (disposed/dependencies), and the hydration sub-surface.
Own, frozen, branded data property — buildTrackedProxy only intercepts
prototype getters, so this own property and its closure-based getters are
proxy-safe. See
createMeta
.
$blac.
BlacMeta<any>.id: string
id})`);
},
};
// Install once at startup. Gate to development so it adds no prod overhead.
Performance note: plugins defeat the single-consumer skip
To deliver onStateChange for every flush, the manager subscribes to each container’s channel with ALL_PATHS interest. That subscription counts as a consumer, which disables the single-consumer fast path in the structural container (where emit would otherwise skip path diffing entirely). The trade-off is intentional — DevTools and persistence genuinely need every change — but it is why a plugin you do not need should stay uninstalled or environment-gated rather than installed-but-disabled.
onStateChange fires once per microtask flush, not once per individual emit/patch/update call. If a method calls patch three times synchronously, onStateChange receives a single call with the final state and the union of all changed paths. Two consequences:
prev is snapshotted once per flush (the state before the first emit of that flush) and reused for every plugin, so the value a plugin sees is independent of install order.
For true per-call granularity (rare), subscribe to the container’s channel directly instead of using a plugin hook.
This batching mirrors onSystemEvent('stateChanged') inside a bloc — the same microtask-coalescing model, seen from the global side rather than the per-instance side. See System Events for the per-instance view and the full rationale for why BlaC batches.