Skip to content

System events

System events are lifecycle hooks inside a single state container instance. Use them to react to that instance’s own state changes, disposal, or hydration status — from within the class, without any external wiring. Every signature on this page is quoted from the @blac/core source.

Why they exist: sometimes a bloc needs to do something in response to its own lifecycle — tear down a timer when it’s disposed, kick off a derived computation when its state settles, log a transition. onSystemEvent is the in-class hook for exactly that. It is the per-instance counterpart to plugins, which observe the same kinds of events but across every instance (see the comparison below).

type
type SystemEvent = "stateChanged" | "dispose" | "hydrationChanged"
SystemEvent
= 'stateChanged' | 'dispose' | 'hydrationChanged';

Register a handler for a lifecycle event on this instance.

protected onSystemEvent = <E extends SystemEvent>(
event: E,
handler: SystemEventHandler<S, E>,
): (() => void)
ParameterTypeRequiredDescription
eventSystemEventyesThe event name: 'stateChanged', 'dispose', or 'hydrationChanged'.
handlerSystemEventHandler<S, E>yesCallback receiving the event payload. The payload shape differs per event (see below).

Returns: () => void — an unsubscribe function. Call it to remove the handler before the instance is disposed. For handlers that should live as long as the instance, you can discard the return value — all handlers are torn down automatically on dispose.

Behavior. A protected method — only callable inside the class. Handlers added for the same event are fired in registration order. Registering in the constructor (or in init) ensures handlers are wired before the instance starts emitting. dispose tears down all registered handlers automatically, so no manual cleanup is needed for instance-lifetime handlers.

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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
} from '@blac/core';
class
class MyCubit
MyCubit
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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
<{
count: number
count
: number }> {
constructor() {
super({
count: number
count
: 0 });
this.
StateContainer<{ count: number; }, void, Record<string, never>>.onSystemEvent: <"stateChanged">(event: "stateChanged", handler: SystemEventHandler<{
count: number;
}, "stateChanged">) => (() => void)
onSystemEvent
('stateChanged', ({
state: {
count: number;
}
state
,
previousState: {
count: number;
}
previousState
}) => {
var console: Console
console
.
Console.log(...data: any[]): void

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

MDN Reference

log
('State changed:',
previousState: {
count: number;
}
previousState
, '->',
state: {
count: number;
}
state
);
});
this.
StateContainer<{ count: number; }, void, Record<string, never>>.onSystemEvent: <"dispose">(event: "dispose", handler: SystemEventHandler<{
count: number;
}, "dispose">) => (() => void)
onSystemEvent
('dispose', () => {
var console: Console
console
.
Console.log(...data: any[]): void

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

MDN Reference

log
('Instance disposed');
});
}
}

The handler argument differs by event. The payload types are:

interface SystemEventPayloads<S> {
stateChanged: { state: S; previousState: S };
dispose: void;
hydrationChanged: {
status: HydrationStatus;
previousStatus: HydrationStatus;
error?: Error;
changedWhileHydrating: boolean;
};
}

Fired once per microtask flush after state changes via emit, update, or patch. Multiple synchronous mutations are coalesced — the handler receives the final state once, not once per call.

Payload fieldTypeDescription
stateSThe final state after the flush.
previousStateSThe state before any mutations in this flush.
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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
} 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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
<{
count: number
count
: number }> {
constructor() {
super({
count: number
count
: 0 });
this.
StateContainer<{ count: number; }, void, Record<string, never>>.onSystemEvent: <"stateChanged">(event: "stateChanged", handler: SystemEventHandler<{
count: number;
}, "stateChanged">) => (() => void)
onSystemEvent
('stateChanged', ({
state: {
count: number;
}
state
,
previousState: {
count: number;
}
previousState
}) => {
// state: the final state after the flush
// previousState: the state before any mutations in this flush
var console: Console
console
.
Console.log(...data: any[]): void

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

MDN Reference

log
(`count: ${
previousState: {
count: number;
}
previousState
.
count: number
count
} -> ${
state: {
count: number;
}
state
.
count: number
count
}`);
});
}
CounterCubit.increment: () => void
increment
= () => this.
StructuralContainer<{ count: number; }>.update(fn: (state: {
count: number;
}) => {
count: number;
}): void
update
((
s: {
count: number;
}
s
) => ({
count: number
count
:
s: {
count: number;
}
s
.
count: number
count
+ 1 }));
}

Fired when the instance is disposed (ref count reaches zero or dispose() is called directly). This is your hook to release anything the instance set up that the registry can’t clean up for you.

Payload: void — no payload; the instance is already disposed when this fires.

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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
} from '@blac/core';
class
class PollingCubit
PollingCubit
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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
<{
data: string | null
data
: string | null }> {
private
PollingCubit.timer: number | null
timer
:
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any

Obtain the return type of a function type

ReturnType
<typeof
function setInterval(handler: TimerHandler, timeout?: number, ...arguments: any[]): number
setInterval
> | null = null;
constructor() {
super({
data: string | null
data
: null });
this.
PollingCubit.timer: number | null
timer
=
function setInterval(handler: TimerHandler, timeout?: number, ...arguments: any[]): number
setInterval
(() => {
this.
StateContainer<{ data: string | null; }, void, Record<string, never>>.patch(partial: {
data?: string | null | 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 legacy listeners and 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
({
data?: string | null | undefined
data
: new
var Date: DateConstructor
new () => Date (+3 overloads)
Date
().
Date.toISOString(): string

Returns a date as a string value in ISO format.

toISOString
() });
}, 1000);
this.
StateContainer<{ data: string | null; }, void, Record<string, never>>.onSystemEvent: <"dispose">(event: "dispose", handler: SystemEventHandler<{
data: string | null;
}, "dispose">) => (() => void)
onSystemEvent
('dispose', () => {
if (this.
PollingCubit.timer: number | null
timer
!== null) {
function clearInterval(id: number | undefined): void
clearInterval
(this.
PollingCubit.timer: number
timer
);
this.
PollingCubit.timer: number | null
timer
= null;
}
});
}
}

Fired when the hydration status changes. Relevant when using the Persistence plugin.

Payload fieldTypeDescription
statusHydrationStatusThe new status: 'idle' | 'hydrating' | 'hydrated' | 'error'.
previousStatusHydrationStatusThe status before the change.
errorError | undefinedPresent when status is 'error'.
changedWhileHydratingbooleantrue if state was modified before hydration completed.

Returns: void.

changedWhileHydrating is the signal that the bloc emitted real state while async hydration was in flight — the persistence layer uses it to decide whether the just-loaded persisted state is stale and should be discarded. See Persistence for the full hydration lifecycle.

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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
} from '@blac/core';
class
class StoredCubit
StoredCubit
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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
<{
value: string
value
: string }> {
constructor() {
super({
value: string
value
: '' });
this.
StateContainer<{ value: string; }, void, Record<string, never>>.onSystemEvent: <"hydrationChanged">(event: "hydrationChanged", handler: SystemEventHandler<{
value: string;
}, "hydrationChanged">) => (() => void)
onSystemEvent
(
'hydrationChanged',
({
status: HydrationStatus
status
,
previousStatus: HydrationStatus
previousStatus
,
error: Error | undefined
error
,
changedWhileHydrating: boolean
changedWhileHydrating
}) => {
if (
status: HydrationStatus
status
=== 'error' &&
error: Error | undefined
error
) {
var console: Console
console
.
Console.error(...data: any[]): void

The console.error() static method outputs a message to the console at the "error" log level. The message is only displayed to the user if the console is configured to display error output. In most cases, the log level is configured within the console UI. The message may be formatted as an error, with red colors and call stack information.

MDN Reference

error
('Hydration failed:',
error: Error
error
.
Error.message: string
message
);
}
if (
changedWhileHydrating: boolean
changedWhileHydrating
) {
var console: Console
console
.
Console.warn(...data: any[]): void

The console.warn() static method outputs a warning message to the console at the "warning" log level. The message is only displayed to the user if the console is configured to display warning output. In most cases, the log level is configured within the console UI. The message may receive special formatting, such as yellow colors and a warning icon.

MDN Reference

warn
(
'State changed before hydration finished — persisted state may be stale',
);
}
var console: Console
console
.
Console.log(...data: any[]): void

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

MDN Reference

log
(`${
previousStatus: HydrationStatus
previousStatus
} -> ${
status: HydrationStatus
status
}`);
},
);
}
}

onSystemEvent returns an unsubscribe function:

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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
} from '@blac/core';
class
class ExampleCubit
ExampleCubit
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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
<{
count: number
count
: number }> {
constructor() {
super({
count: number
count
: 0 });
const
const unsub: () => void
unsub
= this.
StateContainer<{ count: number; }, void, Record<string, never>>.onSystemEvent: <"stateChanged">(event: "stateChanged", handler: SystemEventHandler<{
count: number;
}, "stateChanged">) => (() => void)
onSystemEvent
('stateChanged', ({
state: {
count: number;
}
state
}) => {
var console: Console
console
.
Console.log(...data: any[]): void

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

MDN Reference

log
(
state: {
count: number;
}
state
);
});
// Call unsub() to remove the handler early — before the instance is disposed.
// For instance-lifetime handlers you can discard the return value.
const unsub: () => void
unsub
();
}
}

You only need to call this for handlers with a lifetime shorter than the instance (e.g. one registered conditionally). Handlers that should live for the whole instance need no manual cleanup — dispose tears them all down.

Both observe lifecycle, and they share the same coalesced-flush model — the difference is scope and where the code lives:

System eventsPlugins
ScopeSingle instanceAll instances
AccessInside the class (this.onSystemEvent)Global via getPluginManager()
Use caseInstance-specific side effectsCross-cutting concerns (logging, devtools)

Use system events for cleanup logic, derived computations, or side effects that belong to one specific instance. Use plugins for behavior that applies across all state containers.

A useful way to see the relationship: a stateChanged system handler and a plugin’s onStateChange hook fire off the same underlying flush. The system event is the narrow, in-class view (this instance only, payload { state, previousState }); the plugin hook is the broad, external view (every instance, plus the changed paths and a rich PluginContext). Reach for whichever scope matches the job — don’t install a plugin to do one instance’s cleanup, and don’t try to make a system handler observe the whole app.

  • Plugins — the same lifecycle hooks observed across all instances
  • watch — observe a bloc’s changes from outside the class (rides the same flush)
  • Instance Management — what triggers the dispose event
  • Persistence — the real-world consumer of hydrationChanged