Skip to content

Plugin Authoring

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> | typeof ALL_PATHS
PathSet
,
const ALL_PATHS: typeof ALL_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).
BlacPlugin.onStateChange?<S extends object = any>(ctx: PluginContext, prev: S, next: S, paths: PathSet): void

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> | typeof ALL_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.
BlacPlugin.onHydrationChange?(ctx: PluginContext, status: HydrationStatus, previousStatus: HydrationStatus): void

Fires when a container's hydration status transitions.

onHydrationChange
(
ctx: PluginContext
ctx
,
status: HydrationStatus
status
,
previousStatus: HydrationStatus
previousStatus
) {},
};
HookSignatureWhen it fires
onInstall(ctx)Once at install. ctx.container is undefined.
onUninstall()Once at uninstall. No context.
onCreated(ctx)Synchronously when a container is first acquired.
onStateChange(ctx, prev, next, paths)Once per microtask flush after a state change.
onDestroyed(ctx)Synchronously after a container is disposed.
onHydrationChange(ctx, status, previousStatus)On each hydration status transition.
Internal devtools-only hooks

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.

getPluginManager() returns the singleton PluginManager bound to the global registry. Call it once, near app startup, to install your plugins.

import { getPluginManager } from '@blac/core';
getPluginManager().install(myPlugin, {
enabled: true,
environment: 'development', // 'development' | 'production' | 'test' | 'all'
});
Config optionDefaultMeaning
enabledtrueSet 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):

MethodReturns
getInstanceMetadata(instance){ id, className, isDisposed, name, state, createdAt, args, hydrationStatus, ... }
getState(instance)Current state of the instance
getHydrationStatus(instance)Current HydrationStatus of the instance
startHydration(instance)Begin hydration for the instance
applyHydratedState(instance, state)Apply restored state during hydration
finishHydration(instance)Mark hydration as complete
failHydration(instance, error)Mark hydration as failed
waitForHydration(instance)Promise<void> that resolves when hydration completes
queryInstances(Type)All instances of a given class
getAllTypes()All registered state container classes
getStats(){ registeredTypes, totalInstances, typeBreakdown }
getRefIds(instanceId)Array of ref holder IDs for an instance

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> | typeof ALL_PATHS
PathSet
,
const ALL_PATHS: typeof ALL_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.

BlacPlugin
= {
BlacPlugin.name: string
name
: 'path-logger',
BlacPlugin.version: string
version
: '1.0.0',
BlacPlugin.onStateChange?<S extends object = any>(ctx: PluginContext, prev: S, next: S, paths: PathSet): void

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
) {
const
const container: StateContainer<any, any, any> | undefined
container
=
ctx: PluginContext
ctx
.
PluginContext.container: StateContainer<any, any, any> | undefined

The container this event is about. undefined only for onInstall, which fires once at plugin install time before any container is known.

container
;
if (!
const container: StateContainer<any, any, any> | undefined
container
) return;
if (
paths: PathSet
paths
===
const ALL_PATHS: typeof ALL_PATHS
ALL_PATHS
) {
var console: Console
console
.
Console.log(...data: any[]): void

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

MDN Reference

log
(`[${
const container: StateContainer<any, any, any>
container
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

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
pathId
of
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.

MDN Reference

log
(`[${
const container: StateContainer<any, any, any>
container
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

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 path: string
path
}`);
}
},
};

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 {
function getPluginManager(): 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> | typeof ALL_PATHS
PathSet
,
const ALL_PATHS: typeof ALL_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.

onCreated
(
ctx: PluginContext
ctx
) {
const
const c: StateContainer<any, any, any> | undefined
c
=
ctx: PluginContext
ctx
.
PluginContext.container: StateContainer<any, any, any> | undefined

The container this event is about. undefined only for onInstall, which fires once at plugin install time before any container is known.

container
;
if (
const c: StateContainer<any, any, any> | undefined
c
)
var console: Console
console
.
Console.log(...data: any[]): void

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

MDN Reference

log
(`[audit] created ${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

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
} (${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

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
})`);
},
BlacPlugin.onStateChange?<S extends object = any>(ctx: PluginContext, prev: S, next: S, paths: PathSet): void

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
) {
const
const c: StateContainer<any, any, any> | undefined
c
=
ctx: PluginContext
ctx
.
PluginContext.container: StateContainer<any, any, any> | undefined

The container this event is about. undefined only for onInstall, which fires once at plugin install time before any container is known.

container
;
if (!
const c: StateContainer<any, any, any> | undefined
c
) return;
const
const changed: string[]
changed
=
paths: PathSet
paths
===
const ALL_PATHS: typeof ALL_PATHS
ALL_PATHS
? ['(all)']
: [...
paths: Set<number>
paths
].
Array<number>.map<string>(callbackfn: (value: number, index: number, array: number[]) => string, thisArg?: any): string[]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.

@paramthisArg 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.

MDN Reference

log
(`[audit] ${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

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

onDestroyed
(
ctx: PluginContext
ctx
) {
const
const c: StateContainer<any, any, any> | undefined
c
=
ctx: PluginContext
ctx
.
PluginContext.container: StateContainer<any, any, any> | undefined

The container this event is about. undefined only for onInstall, which fires once at plugin install time before any container is known.

container
;
if (
const c: StateContainer<any, any, any> | undefined
c
)
var console: Console
console
.
Console.log(...data: any[]): void

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

MDN Reference

log
(`[audit] disposed ${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

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
} (${
const c: StateContainer<any, any, any>
c
.
StateContainer<any, any, any>.$blac: BlacMeta<any>

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.
function getPluginManager(): PluginManager

Get the global plugin manager

getPluginManager
().
PluginManager.install(plugin: BlacPlugin, config?: PluginConfig): void

Install a plugin with optional configuration

@paramplugin - The plugin to install

@paramconfig - Optional plugin configuration

@throwsError if plugin is already installed

install
(
const auditPlugin: BlacPlugin
auditPlugin
, {
PluginConfig.environment?: "development" | "production" | "test" | "all" | undefined
environment
: 'development' });
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.

These official plugins are themselves authored against the interface above — read their source as worked examples:

  • Logging — console logging and monitoring
  • DevTools — Chrome DevTools integration
  • Persistence — IndexedDB state persistence (the real consumer of the hydration methods on ctx)
  • Plugin Overview — the plugin catalog and where to start
  • System Events — per-instance lifecycle hooks and when to use them instead
  • Persistence — a complete plugin that drives the hydration API
  • Glossary — definitions for plugin, hydration, interner, and PathSet