Skip to content

Events

Some concerns cut across every bloc: “user logged out → reset all session-scoped state,” “workspace switched → every open panel should re-sync.” watch/onSystemEvent describe a single container’s own lifecycle; they are the wrong tool for “broadcast this and let whoever cares react.” defineEvent/emitEvent/onEvent are the typed pub/sub for exactly that, and this.on(...) is the owner-scoped form that ties a subscription to a container’s lifetime.

This replaces “walk every live instance and call a magic method if present” bus patterns: dispatch cost here is O(subscribers for that token), never O(all live instances), and every handler is typed to the token’s payload.

Create a typed event token.

function defineEvent<P = void>(name: string): EventToken<P>;
ParameterTypeRequiredDescription
namestringyesA human-readable identifier for the event, used in error messages and logs.

Returns: an EventToken<P> — an opaque handle carrying the payload type P. Define tokens once, at module scope, and import them wherever you emit or listen.

import {
function defineEvent<P = void>(name: string): EventToken<P>
defineEvent
} from '@blac/core';
// A payload-carrying token
const
const UserLoggedOut: EventToken<{
userId: string;
}>
UserLoggedOut
=
defineEvent<{
userId: string;
}>(name: string): EventToken<{
userId: string;
}>
defineEvent
<{
userId: string
userId
: string }>('UserLoggedOut');
// A payload-less token
const
const AppResumed: EventToken<void>
AppResumed
=
defineEvent<void>(name: string): EventToken<void>
defineEvent
('AppResumed');

Broadcast an event to every current subscriber of that token.

function emitEvent<P>(
token: EventToken<P>,
...payload: 0 extends 1 & P // isAny
? [payload?: unknown]
: [P] extends [void]
? []
: [P]
): void;
ParameterTypeRequiredDescription
tokenEventToken<P>yesThe token to dispatch on.
payloadPwhen P is not voidTyped payload. Omitting it for a payload-carrying token is a compile error.

Returns: void.

Behavior. Dispatch is synchronous and runs subscribers in registration order. It costs nothing if there are no subscribers — emitEvent on a token nobody listens to is a cheap no-op, not a registry walk. Each handler runs in its own try/catch: a throwing handler is isolated (it logs to console.error and the remaining handlers still run — no handler is ever silently skipped by another’s bug). Re-entrant emitEvent/onEvent calls from inside a handler are safe: dispatch iterates a snapshot of the subscriber set taken at the start of the call, so a handler that subscribes a new listener during dispatch does not get that listener invoked for the in-flight emit.

import {
function defineEvent<P = void>(name: string): EventToken<P>
defineEvent
,
function emitEvent<P>(token: EventToken<P>, ...payload: 0 extends 1 & P ? [payload?: unknown] : [P] extends [void] ? [] : [P]): void

Dispatch token to every current subscriber on the ambient registry, in registration order.

Cost is O(subscribers for this token) — a token with no subscribers (or no bus yet on this registry) returns immediately, never touching live instances.

Dispatch iterates a SNAPSHOT of the handler Set, so it is safe for a handler to synchronously call emitEvent/onEvent re-entrantly: a nested emitEvent for the same token completes against its own snapshot, and a handler registered via onEvent during dispatch is NOT invoked for the in-flight emit (it only sees emits that start after it subscribed).

The variadic tuple means a void-payload token needs no second argument, and a typed-payload token requires one. It wraps both sides in a tuple ([P] extends [void], not void extends P) because the naive form is true for every P that void is assignable to — unknown, any, number | void — which collapsed the tuple to [] and made their payload un-passable. any is special-cased first so it accepts either form.

emitEvent
} from '@blac/core';
const
const UserLoggedOut: EventToken<{
userId: string;
}>
UserLoggedOut
=
defineEvent<{
userId: string;
}>(name: string): EventToken<{
userId: string;
}>
defineEvent
<{
userId: string
userId
: string }>('UserLoggedOut');
emitEvent<{
userId: string;
}>(token: EventToken<{
userId: string;
}>, payload_0: {
userId: string;
}): void

Dispatch token to every current subscriber on the ambient registry, in registration order.

Cost is O(subscribers for this token) — a token with no subscribers (or no bus yet on this registry) returns immediately, never touching live instances.

Dispatch iterates a SNAPSHOT of the handler Set, so it is safe for a handler to synchronously call emitEvent/onEvent re-entrantly: a nested emitEvent for the same token completes against its own snapshot, and a handler registered via onEvent during dispatch is NOT invoked for the in-flight emit (it only sees emits that start after it subscribed).

The variadic tuple means a void-payload token needs no second argument, and a typed-payload token requires one. It wraps both sides in a tuple ([P] extends [void], not void extends P) because the naive form is true for every P that void is assignable to — unknown, any, number | void — which collapsed the tuple to [] and made their payload un-passable. any is special-cased first so it accepts either form.

emitEvent
(
const UserLoggedOut: EventToken<{
userId: string;
}>
UserLoggedOut
, {
userId: string
userId
: 'user-42' });
const
const AppResumed: EventToken<void>
AppResumed
=
defineEvent<void>(name: string): EventToken<void>
defineEvent
('AppResumed');
emitEvent<void>(token: EventToken<void>): void

Dispatch token to every current subscriber on the ambient registry, in registration order.

Cost is O(subscribers for this token) — a token with no subscribers (or no bus yet on this registry) returns immediately, never touching live instances.

Dispatch iterates a SNAPSHOT of the handler Set, so it is safe for a handler to synchronously call emitEvent/onEvent re-entrantly: a nested emitEvent for the same token completes against its own snapshot, and a handler registered via onEvent during dispatch is NOT invoked for the in-flight emit (it only sees emits that start after it subscribed).

The variadic tuple means a void-payload token needs no second argument, and a typed-payload token requires one. It wraps both sides in a tuple ([P] extends [void], not void extends P) because the naive form is true for every P that void is assignable to — unknown, any, number | void — which collapsed the tuple to [] and made their payload un-passable. any is special-cased first so it accepts either form.

emitEvent
(
const AppResumed: EventToken<void>
AppResumed
); // void payload — no second argument

Subscribe to an event from outside a container (scripts, tests, other infrastructure).

function onEvent<P>(token: EventToken<P>, handler: (payload: P) => void): () => void;
ParameterTypeRequiredDescription
tokenEventToken<P>yesThe token to listen for.
handler(payload: P) => voidyesCalled with the typed payload on emit.

Returns: a () => void unsubscribe function. Calling it more than once is safe (idempotent).

import {
function defineEvent<P = void>(name: string): EventToken<P>
defineEvent
,
function emitEvent<P>(token: EventToken<P>, ...payload: 0 extends 1 & P ? [payload?: unknown] : [P] extends [void] ? [] : [P]): void

Dispatch token to every current subscriber on the ambient registry, in registration order.

Cost is O(subscribers for this token) — a token with no subscribers (or no bus yet on this registry) returns immediately, never touching live instances.

Dispatch iterates a SNAPSHOT of the handler Set, so it is safe for a handler to synchronously call emitEvent/onEvent re-entrantly: a nested emitEvent for the same token completes against its own snapshot, and a handler registered via onEvent during dispatch is NOT invoked for the in-flight emit (it only sees emits that start after it subscribed).

The variadic tuple means a void-payload token needs no second argument, and a typed-payload token requires one. It wraps both sides in a tuple ([P] extends [void], not void extends P) because the naive form is true for every P that void is assignable to — unknown, any, number | void — which collapsed the tuple to [] and made their payload un-passable. any is special-cased first so it accepts either form.

emitEvent
,
function onEvent<P>(token: EventToken<P>, handler: (payload: P) => void): () => void

Subscribe on the ambient registry (getRegistry()).

onEvent
} from '@blac/core';
const
const UserLoggedOut: EventToken<{
userId: string;
}>
UserLoggedOut
=
defineEvent<{
userId: string;
}>(name: string): EventToken<{
userId: string;
}>
defineEvent
<{
userId: string
userId
: string }>('UserLoggedOut');
const
const off: () => void
off
=
onEvent<{
userId: string;
}>(token: EventToken<{
userId: string;
}>, handler: (payload: {
userId: string;
}) => void): () => void

Subscribe on the ambient registry (getRegistry()).

onEvent
(
const UserLoggedOut: EventToken<{
userId: string;
}>
UserLoggedOut
, ({
userId: string
userId
}) => {
var console: Console
console
.
Console.log(...data: any[]): void

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

MDN Reference

log
('logged out:',
userId: string
userId
);
});
emitEvent<{
userId: string;
}>(token: EventToken<{
userId: string;
}>, payload_0: {
userId: string;
}): void

Dispatch token to every current subscriber on the ambient registry, in registration order.

Cost is O(subscribers for this token) — a token with no subscribers (or no bus yet on this registry) returns immediately, never touching live instances.

Dispatch iterates a SNAPSHOT of the handler Set, so it is safe for a handler to synchronously call emitEvent/onEvent re-entrantly: a nested emitEvent for the same token completes against its own snapshot, and a handler registered via onEvent during dispatch is NOT invoked for the in-flight emit (it only sees emits that start after it subscribed).

The variadic tuple means a void-payload token needs no second argument, and a typed-payload token requires one. It wraps both sides in a tuple ([P] extends [void], not void extends P) because the naive form is true for every P that void is assignable to — unknown, any, number | void — which collapsed the tuple to [] and made their payload un-passable. any is special-cased first so it accepts either form.

emitEvent
(
const UserLoggedOut: EventToken<{
userId: string;
}>
UserLoggedOut
, {
userId: string
userId
: 'user-42' });
const off: () => void
off
(); // stop listening

this.on(token, handler) — the owner-scoped form

Section titled “this.on(token, handler) — the owner-scoped form”

Inside a container, prefer this.on(...) over the standalone onEvent: the subscription is automatically torn down when the container disposes, so there is no manual off to remember in consumer code.

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
,
function defineEvent<P = void>(name: string): EventToken<P>
defineEvent
} from '@blac/core';
const
const UserLoggedOut: EventToken<{
userId: string;
}>
UserLoggedOut
=
defineEvent<{
userId: string;
}>(name: string): EventToken<{
userId: string;
}>
defineEvent
<{
userId: string
userId
: string }>('UserLoggedOut');
interface
interface SessionState
SessionState
{
SessionState.userId: string | null
userId
: string | null;
}
class
class SessionCubit
SessionCubit
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
<
interface SessionState
SessionState
> {
constructor() {
super({
SessionState.userId: string | null
userId
: null });
// Auto-unsubscribed on dispose — no manual teardown needed here.
this.
StateContainer<SessionState, void, Record<string, never>>.on: <{
userId: string;
}>(token: EventToken<{
userId: string;
}>, handler: (payload: {
userId: string;
}) => void) => (() => void)

Subscribe to a cross-cutting application event for this container's lifetime. The subscription is torn down automatically on dispose() — no manual off in consumer code (requirements AC3).

Uses THIS container's registry (_registry, captured at construction), not the ambient one, so a container always listens on the bus of the registry it belongs to.

on
(
const UserLoggedOut: EventToken<{
userId: string;
}>
UserLoggedOut
, () => {
this.
StructuralContainer<SessionState>.update(fn: (state: SessionState) => SessionState): void
update
(() => ({
SessionState.userId: string | null
userId
: null }));
});
}
}

Behavior. this.on is protected — only callable from inside the class. It listens on this container’s own registry (the one it was constructed in), not whichever registry happens to be ambient, so a container always reacts to events on the bus of the registry it belongs to — this matters for test isolation (withTestRegistry/blacTestSetup). A disposed container never receives events: dispose fires its 'dispose' handlers (which includes the unsubscribe registered by this.on) before anything else runs, so the subscription is already gone by the time any later emit happens. A keepAlive container keeps receiving events for as long as it stays alive — nothing special to do.

  • Ordering. Handlers for a token run in the order they were registered.
  • Isolation. A throwing handler does not stop the others; the error is logged and dispatch continues.
  • Cost. O(subscribers for the token). Emitting an event with zero subscribers costs nothing extra — there is no per-instance walk anywhere in the path.
  • Re-entrancy. emitEvent called from inside a handler runs synchronously and completes before the outer dispatch resumes. A handler that registers a new onEvent/this.on during dispatch is not called for the emit that’s already in flight.
  • Registry isolation. Subscriptions are scoped per registry, so two independent test registries (withTestRegistry, blacTestSetup) never see each other’s subscribers.
  • System Events — per-instance lifecycle hooks (onSystemEvent), the counterpart for a container’s own state/dispose/hydration transitions
  • Bloc Communicationdepend() and cross-bloc composition for concerns that aren’t cross-cutting broadcasts
  • Testing core logicwithTestRegistry/blacTestSetup and why event subscriptions are isolated per registry