Skip to content

Bloc communication

Real apps are made of several focused blocs — a cart, a shipping calculator, an auth session — that need to read each other’s state or trigger each other’s behavior. The naive fix is to merge them into one giant Cubit, but that destroys the very separation that makes each piece testable and reusable.

depend() is the answer: it lets one Cubit declare a dependency on another and read its state (or call its methods) without holding a hard reference and without the two classes importing each other’s instances. The dependency is resolved lazily from the registry, so each bloc stays decoupled from how the other is created or keyed.

Declare a cross-bloc dependency from inside a Cubit. Returns a branded handle with two accessors: .untracked() resolves the other instance from the registry on each call, and .track() does the same plus opts the reading React consumer into automatic cross-bloc re-renders.

protected depend<T extends StateContainerConstructor>(
Type: T,
defaultArgs?: ExtractArgs<T>,
): DepHandle<T>
ParameterTypeRequiredDescription
TypeT extends StateContainerConstructoryesThe state-container class to depend on.
defaultArgsExtractArgs<T>noThe args that identify which keyed instance to resolve when an accessor is called without its own args. Defaults to the default instance.

Returns: a DepHandle<T> — a branded handle with two methods:

  • handle.untracked(options?) resolves the live instance from the registry lazily, on each call. Use it for imperative method calls (this.shipping.untracked().recalc()) and one-off state reads that should not subscribe the reader.
  • handle.track(options?) resolves the same instance and, inside a React render, opts the current consumer into automatic cross-bloc re-renders — see Auto-tracking with .track().

Both accept an optional { args } to resolve a specific keyed instance at call time (overriding defaultArgs), so the resolved instance can derive from current state. The handle is returned immediately at declaration time; resolution happens each time you call an accessor.

Behavior. depend() records the dependency (the TypeinstanceKey pair is stored on the instance), then returns a handle whose accessors call this._registry.ensure(Type, key) on each invocation. Resolution is lazy per call, which keeps the surface immune to dep-instance churn — if the depended-on instance is disposed and recreated, the next accessor call simply returns the new one. .untracked() does not wire a reactive subscription between the two blocs: this.shipping.untracked().state.rate is a plain read inside the bloc. Reactivity comes from the consumer — .track() inside a tracked getter (a React component’s auto-tracking proxy), or an explicit watch(). A naive always-on auto-bridge would loop on mutual deps, which is why tracking is opt-in per read.

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 ShippingCubit
ShippingCubit
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
<{
rate: number
rate
: number }> {
constructor() {
super({
rate: number
rate
: 5.99 });
}
}
interface
interface CartItem
CartItem
{
CartItem.price: number
price
: number;
}
class
class CartCubit
CartCubit
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
<{
items: CartItem[]
items
:
interface CartItem
CartItem
[] }> {
private
CartCubit.shipping: DepHandle<typeof ShippingCubit>
shipping
= this.
StateContainer<{ items: CartItem[]; }, void, Record<string, never>>.depend<typeof ShippingCubit>(Type: typeof ShippingCubit, defaultArgs?: void | undefined): DepHandle<typeof ShippingCubit>

Declare a cross-bloc dependency. Returns a branded handle with two accessors — the dep instance is resolved against the registry on each call, which keeps the surface immune to dep-instance churn:

  • handle.track(options?) — reactive read. Returns [state, instance]. Inside a getter reached through the React proxy this subscribes the reading component to the dep's changes (base impl: live, no subscription).
  • handle.untracked(options?) — returns the live instance with no tracking, for imperative method calls and one-off reads.

defaultArgs resolves the dep instance when an accessor is called without its own args; per-call options.args overrides it and can derive from current state. This does NOT auto-resubscribe outside the React proxy; non-React consumers needing updates should subscribe explicitly.

depend
(
class ShippingCubit
ShippingCubit
);
constructor() {
super({
items: CartItem[]
items
: [] });
}
get
CartCubit.total: number
total
() {
const
const subtotal: number
subtotal
= this.
StructuralContainer<{ items: CartItem[]; }>.state: {
items: CartItem[];
}
state
.
items: CartItem[]
items
.
Array<CartItem>.reduce<number>(callbackfn: (previousValue: number, currentValue: CartItem, currentIndex: number, array: CartItem[]) => number, initialValue: number): number (+2 overloads)

Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function.

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

@paraminitialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value.

reduce
((
sum: number
sum
,
i: CartItem
i
) =>
sum: number
sum
+
i: CartItem
i
.
CartItem.price: number
price
, 0);
// Plain (untracked) read — see `.track()` below for the reactive version.
return
const subtotal: number
subtotal
+ this.
CartCubit.shipping: DepHandle<typeof ShippingCubit>
shipping
.
DepHandle<typeof ShippingCubit>.untracked(options?: DepAccessOptions<typeof ShippingCubit> | undefined): ShippingCubit
untracked
().
StructuralContainer<{ rate: number; }>.state: {
rate: number;
}
state
.
rate: number
rate
;
}
}
  1. this.depend(ShippingCubit) records the dependency (ShippingCubit → instance key) and returns a DepHandle<ShippingCubit>.
  2. Calling .untracked() (or .track()) resolves the instance via ensure() from the registry — creating it if it does not exist yet.
  3. The dependency is resolved lazily on every call, not when you declare it. This keeps CartCubit immune to dep-instance churn: if the depended-on instance is disposed and recreated, the next shipping.untracked() call simply returns the new one.
  4. When a React component reads cart.total, reactivity comes from the render-time tracker. Plain this.shipping.untracked().state.rate reads a live (untracked) instance — the component re-renders only if it is also subscribed to ShippingCubit via useBloc. To opt into automatic cross-bloc subscriptions without a second useBloc call, use .track() on the handle.

By default, depend(Type) targets the 'default' instance key. To depend on a specific named instance, pass the defaultArgs that identify it (or override per call with .track({ args }) / .untracked({ args })):

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 EditorCubit
EditorCubit
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
<{
content: string
content
: string }, {
docId: string
docId
: string }> {
static
EditorCubit.key: (a: {
docId: string;
}) => string
key
= (
a: {
docId: string;
}
a
: {
docId: string
docId
: string }) =>
a: {
docId: string;
}
a
.
docId: string
docId
;
constructor() {
super({
content: string
content
: '' });
}
}
class
class ReviewCubit
ReviewCubit
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
<{
approved: boolean
approved
: boolean }> {
private
ReviewCubit.editor: DepHandle<typeof EditorCubit>
editor
= this.
StateContainer<{ approved: boolean; }, void, Record<string, never>>.depend<typeof EditorCubit>(Type: typeof EditorCubit, defaultArgs?: {
docId: string;
} | undefined): DepHandle<typeof EditorCubit>

Declare a cross-bloc dependency. Returns a branded handle with two accessors — the dep instance is resolved against the registry on each call, which keeps the surface immune to dep-instance churn:

  • handle.track(options?) — reactive read. Returns [state, instance]. Inside a getter reached through the React proxy this subscribes the reading component to the dep's changes (base impl: live, no subscription).
  • handle.untracked(options?) — returns the live instance with no tracking, for imperative method calls and one-off reads.

defaultArgs resolves the dep instance when an accessor is called without its own args; per-call options.args overrides it and can derive from current state. This does NOT auto-resubscribe outside the React proxy; non-React consumers needing updates should subscribe explicitly.

depend
(
class EditorCubit
EditorCubit
, {
docId: string
docId
: 'doc-42' });
constructor() {
super({
approved: boolean
approved
: false });
}
}

The args passed here must resolve to the same key the target instance is acquired under (via its static key or structural hash). Mismatched args resolve a different instance — see Inputs and identity for how keys are derived.

For one-off access outside the class constructor, use registry functions directly:

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
,
function borrow<T extends StateContainerConstructor>(BlocClass: T, target?: BorrowTarget<T>): InstanceType<T>
borrow
} from '@blac/core';
interface
interface NotificationState
NotificationState
{
NotificationState.message: string
message
: string;
}
class
class UserCubit
UserCubit
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
<{
name: string
name
: string }> {
constructor() {
super({
name: string
name
: '' });
}
}
class
class NotificationCubit
NotificationCubit
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
<
interface NotificationState
NotificationState
> {
constructor() {
super({
NotificationState.message: string
message
: '' });
}
NotificationCubit.showUserError: () => void
showUserError
= () => {
const
const user: UserCubit
user
=
borrow<typeof UserCubit>(BlocClass: typeof UserCubit, target?: BorrowTarget<typeof UserCubit> | undefined): UserCubit
borrow
(
class UserCubit
UserCubit
); // must already exist
this.
StateContainer<NotificationState, void, Record<string, never>>.patch(partial: {
message?: string | 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
({
message?: string | undefined
message
: `Error for ${
const user: UserCubit
user
.
StructuralContainer<{ name: string; }>.state: {
name: string;
}
state
.
name: string
name
}` });
};
}
ApproachUse when
this.depend(Class)Ongoing dependency used in getters or multiple methods
ensure(Class)One-off access; creates if missing
borrow(Class)One-off access; instance must already exist

Lifecycle: who keeps the dependency alive?

Section titled “Lifecycle: who keeps the dependency alive?”

depend() resolves through ensure(), and ensure() does not take a reference — it creates the instance if needed but does not increment the dependency’s ref count. This has a concrete consequence worth internalizing:

A bloc you depend() on is not kept alive by you. If nothing else holds a reference to it, the registry may dispose it the moment its own ref count hits zero.

In practice this is usually fine, because the depended-on bloc is also mounted somewhere (a component holds a ref via useBloc). But it is a real gotcha when the dependency is a “pure derived” service that no component renders directly.

There are two clean ways to guarantee a dependency stays alive:

  • keepAlive — mark the dependency class with @blac({ keepAlive: true }) so the registry never auto-disposes it. See Configuration.
  • The cascade does the right thing on teardown. When a bloc that created its dependencies (via ensure) is itself disposed and reaches zero refs, the registry cascades disposal to those deps if they too have zero refs and are not keepAlive. So a depend()-only dependency graph tears itself down cleanly without leaking.
Why lazy resolution matters here

Because the handle re-resolves on every call, a dependency that was disposed out from under you is not a dangling pointer — the next shipping.untracked() simply re-creates a fresh instance via ensure(). The cost is that any state the old instance held is gone. If that state must survive, use keepAlive rather than relying on re-creation.

Avoiding cycles and constructor-time reads

Section titled “Avoiding cycles and constructor-time reads”

Two cross-bloc patterns reliably cause bugs. Both are easy to avoid once you know the rule: resolve dependencies lazily, read them late.

When you find yourself wanting a cycle, it usually signals that two blocs are really one concern, or that a piece of shared state belongs in a third, lower-level bloc. See Best Practices for when cross-bloc coupling is a smell versus a sound dependency.

Dependencies aren’t just for reading state — you can call methods on them to trigger side effects in other blocs.

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';
interface
interface Message
Message
{
Message.userId: string
userId
: string;
Message.text: string
text
: string;
}
interface
interface ChannelState
ChannelState
{
ChannelState.channelId: string
channelId
: string;
ChannelState.messages: Message[]
messages
:
interface Message
Message
[];
}
class
class NotificationCubit
NotificationCubit
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
<{
unread: number
unread
: number }> {
constructor() {
super({
unread: number
unread
: 0 });
}
NotificationCubit.incrementUnread: (_channelId: string) => void
incrementUnread
= (
_channelId: string
_channelId
: string) => {
this.
StructuralContainer<{ unread: number; }>.update(fn: (state: {
unread: number;
}) => {
unread: number;
}): void
update
((
s: {
unread: number;
}
s
) => ({
unread: number
unread
:
s: {
unread: number;
}
s
.
unread: number
unread
+ 1 }));
};
NotificationCubit.clearUnread: (_channelId: string) => void
clearUnread
= (
_channelId: string
_channelId
: string) => {
this.
StateContainer<{ unread: number; }, void, Record<string, never>>.emit(next: {
unread: number;
}): void
emit
({
unread: number
unread
: 0 });
};
}
class
class ChannelCubit
ChannelCubit
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
<
interface ChannelState
ChannelState
> {
private
ChannelCubit.notifications: DepHandle<typeof NotificationCubit>
notifications
= this.
StateContainer<ChannelState, void, Record<string, never>>.depend<typeof NotificationCubit>(Type: typeof NotificationCubit, defaultArgs?: void | undefined): DepHandle<typeof NotificationCubit>

Declare a cross-bloc dependency. Returns a branded handle with two accessors — the dep instance is resolved against the registry on each call, which keeps the surface immune to dep-instance churn:

  • handle.track(options?) — reactive read. Returns [state, instance]. Inside a getter reached through the React proxy this subscribes the reading component to the dep's changes (base impl: live, no subscription).
  • handle.untracked(options?) — returns the live instance with no tracking, for imperative method calls and one-off reads.

defaultArgs resolves the dep instance when an accessor is called without its own args; per-call options.args overrides it and can derive from current state. This does NOT auto-resubscribe outside the React proxy; non-React consumers needing updates should subscribe explicitly.

depend
(
class NotificationCubit
NotificationCubit
);
constructor() {
super({
ChannelState.channelId: string
channelId
: '',
ChannelState.messages: Message[]
messages
: [] });
}
ChannelCubit.receiveMessage: (message: Message) => void
receiveMessage
= (
message: Message
message
:
interface Message
Message
) => {
this.
StructuralContainer<ChannelState>.update(fn: (state: ChannelState) => ChannelState): void
update
((
s: ChannelState
s
) => ({
...
s: ChannelState
s
,
ChannelState.messages: Message[]
messages
: [...
s: ChannelState
s
.
ChannelState.messages: Message[]
messages
,
message: Message
message
],
}));
// Trigger a side effect in another bloc. `.untracked()` resolves the live
// instance without subscribing — a method call needs no reactivity.
this.
ChannelCubit.notifications: DepHandle<typeof NotificationCubit>
notifications
.
DepHandle<typeof NotificationCubit>.untracked(options?: DepAccessOptions<typeof NotificationCubit> | undefined): NotificationCubit
untracked
().
NotificationCubit.incrementUnread: (_channelId: string) => void
incrementUnread
(this.
StructuralContainer<ChannelState>.state: ChannelState
state
.
ChannelState.channelId: string
channelId
);
};
ChannelCubit.markAsRead: () => void
markAsRead
= () => {
this.
ChannelCubit.notifications: DepHandle<typeof NotificationCubit>
notifications
.
DepHandle<typeof NotificationCubit>.untracked(options?: DepAccessOptions<typeof NotificationCubit> | undefined): NotificationCubit
untracked
().
NotificationCubit.clearUnread: (_channelId: string) => void
clearUnread
(this.
StructuralContainer<ChannelState>.state: ChannelState
state
.
ChannelState.channelId: string
channelId
);
};
}

This keeps notification logic in NotificationCubit while letting ChannelCubit coordinate when it fires.

Getters that read from multiple blocs with .track() are wired through the proxy: a component reading dashboard.summary subscribes to every dependency path the getter touches (auth.user.name, cart.items), and re-renders only when one of those changes.

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 AuthCubit
AuthCubit
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
<{
user: {
name: string;
} | null
user
: {
name: string
name
: string } | null }> {
constructor() {
super({
user: {
name: string;
} | null
user
: null });
}
}
class
class CartCubit
CartCubit
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
<{
items: string[]
items
: string[] }> {
constructor() {
super({
items: string[]
items
: [] });
}
}
class
class DashboardCubit
DashboardCubit
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
<
type Record<K extends keyof any, T> = { [P in K]: T; }

Construct a type with a set of properties K of type T

Record
<string, never>> {
private
DashboardCubit.auth: DepHandle<typeof AuthCubit>
auth
= this.
StateContainer<Record<string, never>, void, Record<string, never>>.depend<typeof AuthCubit>(Type: typeof AuthCubit, defaultArgs?: void | undefined): DepHandle<typeof AuthCubit>

Declare a cross-bloc dependency. Returns a branded handle with two accessors — the dep instance is resolved against the registry on each call, which keeps the surface immune to dep-instance churn:

  • handle.track(options?) — reactive read. Returns [state, instance]. Inside a getter reached through the React proxy this subscribes the reading component to the dep's changes (base impl: live, no subscription).
  • handle.untracked(options?) — returns the live instance with no tracking, for imperative method calls and one-off reads.

defaultArgs resolves the dep instance when an accessor is called without its own args; per-call options.args overrides it and can derive from current state. This does NOT auto-resubscribe outside the React proxy; non-React consumers needing updates should subscribe explicitly.

depend
(
class AuthCubit
AuthCubit
);
private
DashboardCubit.cart: DepHandle<typeof CartCubit>
cart
= this.
StateContainer<Record<string, never>, void, Record<string, never>>.depend<typeof CartCubit>(Type: typeof CartCubit, defaultArgs?: void | undefined): DepHandle<typeof CartCubit>

Declare a cross-bloc dependency. Returns a branded handle with two accessors — the dep instance is resolved against the registry on each call, which keeps the surface immune to dep-instance churn:

  • handle.track(options?) — reactive read. Returns [state, instance]. Inside a getter reached through the React proxy this subscribes the reading component to the dep's changes (base impl: live, no subscription).
  • handle.untracked(options?) — returns the live instance with no tracking, for imperative method calls and one-off reads.

defaultArgs resolves the dep instance when an accessor is called without its own args; per-call options.args overrides it and can derive from current state. This does NOT auto-resubscribe outside the React proxy; non-React consumers needing updates should subscribe explicitly.

depend
(
class CartCubit
CartCubit
);
constructor() {
super({});
}
get
DashboardCubit.summary: string
summary
() {
const [
const authState: Readonly<{
user: {
name: string;
} | null;
}>
authState
] = this.
DashboardCubit.auth: DepHandle<typeof AuthCubit>
auth
.
DepHandle<typeof AuthCubit>.track(options?: DepAccessOptions<typeof AuthCubit> | undefined): [Readonly<{
user: {
name: string;
} | null;
}>, AuthCubit]
track
();
const [
const cartState: Readonly<{
items: string[];
}>
cartState
] = this.
DashboardCubit.cart: DepHandle<typeof CartCubit>
cart
.
DepHandle<typeof CartCubit>.track(options?: DepAccessOptions<typeof CartCubit> | undefined): [Readonly<{
items: string[];
}>, CartCubit]
track
();
const
const user: {
name: string;
} | null
user
=
const authState: Readonly<{
user: {
name: string;
} | null;
}>
authState
.
user: {
name: string;
} | null
user
;
const
const itemCount: number
itemCount
=
const cartState: Readonly<{
items: string[];
}>
cartState
.
items: string[]
items
.
Array<string>.length: number

Gets or sets the length of the array. This is a number one higher than the highest index in the array.

length
;
return `${
const user: {
name: string;
} | null
user
?.
name: string | undefined
name
?? 'Guest'} has ${
const itemCount: number
itemCount
} items`;
}
}

this.depend(OtherBloc).untracked() gets the live instance. Reading its state inside a getter — this.shipping.untracked().state.rate — is a plain live read. A React consumer won’t re-render when ShippingCubit emits unless the component also calls useBloc(ShippingCubit) itself.

Calling .track() on the handle opts the current render’s consumer into automatic cross-bloc subscriptions, without a second useBloc at the component level:

import { Cubit } from '@blac/core';
class PriceBloc extends Cubit<{ amount: number }> {
constructor() {
super({ amount: 100 });
}
}
class CartBloc extends Cubit<{ qty: number }> {
private price = this.depend(PriceBloc);
constructor() {
super({ qty: 2 });
}
get total() {
const [priceState] = this.price.track(); // opt in to cross-bloc tracking
return this.state.qty * priceState.amount;
}
}

A component that reads cart.total during render auto-subscribes to both CartBloc and PriceBloc — no useBloc(PriceBloc) needed:

// In a React component:
const [, cart] = useBloc(CartBloc);
return <span>{cart.total}</span>; // re-renders when qty OR price.amount changes
handle.track(); // → [trackedState, depProxy]
ElementTypeDescription
trackedStatedep’s stateSnapshot recorded by the render-time tracker. Access fields here to record leaf paths (e.g. priceState.amount).
depProxydep instance proxyThe dep wrapped in a tracking proxy. Use this to call getters on the dep; those getters’ own this.state.x reads are also tracked (see Dep getter transitivity).

.track() is render-aware. Outside a React render — in an event handler, an effect, or a plain method — it degrades gracefully to live values with no subscription side effects:

import { Cubit } from '@blac/core';
class PriceBloc extends Cubit<{ amount: number }> {
constructor() {
super({ amount: 100 });
}
}
class CartBloc extends Cubit<{ qty: number }> {
private price = this.depend(PriceBloc);
constructor() {
super({ qty: 2 });
}
get total() {
const [priceState] = this.price.track();
return this.state.qty * priceState.amount;
}
logTotal() {
// Called outside render — .track() returns live values, no subscription registered.
const [priceState] = this.price.track();
console.log('total:', this.state.qty * priceState.amount);
}
}

You can safely call .track() from methods without worrying about accidentally registering subscriptions at the wrong time.

The second element of .track() — the depProxy — threads tracking through the dep’s own getters. If the dep’s getter reads this.state.field, that read is also recorded for the consumer:

import { Cubit } from '@blac/core';
class SrcBloc extends Cubit<{ count: number }> {
constructor() {
super({ count: 5 });
}
get doubled() {
return this.state.count * 2;
}
}
class AggBloc extends Cubit<{ offset: number }> {
private src = this.depend(SrcBloc);
constructor() {
super({ offset: 0 });
}
get computed() {
const [, s] = this.src.track();
// `s.doubled` calls the getter through the dep proxy. Inside `doubled`,
// `this.state.count` is intercepted and records `count` as a tracked path
// for the current consumer — so a SrcBloc emit on `count` wakes the consumer.
return this.state.offset + s.doubled;
}
}

This works transitively for deep chains: if A.track(B) and inside B.computed there is B.track(C), a consumer reading A.computed is subscribed to C’s channel too. Bumping C wakes the consumer.

.track() is called during the render of the getter. If it runs conditionally, the subscription is added or dropped automatically after each render:

import { Cubit } from '@blac/core';
class FeatureFlagBloc extends Cubit<{ enabled: boolean }> {
constructor() {
super({ enabled: false });
}
}
class LivePriceBloc extends Cubit<{ price: number }> {
constructor() {
super({ price: 0 });
}
}
class ProductBloc extends Cubit<{ base: number }> {
private flags = this.depend(FeatureFlagBloc);
private livePrice = this.depend(LivePriceBloc);
constructor() {
super({ base: 10 });
}
get display() {
const [flags] = this.flags.track(); // always tracked
if (flags.enabled) {
const [p] = this.livePrice.track(); // only tracked when feature is on
return p.price;
}
return this.state.base;
}
}

When flags.enabled becomes false on the next render, the LivePriceBloc subscription and ref are released. Turning it back on re-subscribes.

Two blocs can safely track each other (A.track(B) + B.track(A)). The reconciler detects re-entrant tracking within the same render and unions the paths rather than re-acquiring. No infinite loop occurs.

When a consumer uses the select option on useBloc, .track() degrades to live values (same as outside-render). The select callback runs against the primary bloc only. This is by design — select is a manual subscription that opts out of auto-tracking entirely.

Sometimes a dependency doesn’t exist yet and needs to be created conditionally. Use borrowSafe to check and acquire to create on demand:

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
,
function borrowSafe<T extends StateContainerConstructor>(BlocClass: T, target?: BorrowTarget<T>): {
error: Error;
instance: null;
} | {
error: null;
instance: InstanceType<T>;
}
borrowSafe
,
function acquire<T extends StateContainerConstructor>(BlocClass: T, opts?: {
args?: ExtractArgs<T>;
refId?: string;
}): InstanceType<T>

Acquire an instance with ref tracking (ownership semantics). Instance identity is derived purely from args (via the class's static key(args), a structural hash of args, or the default sentinel when there are none).

@paramBlocClass - The StateContainer class constructor

@paramopts.args - Construction/identity args; derives the instance key

@paramopts.refId - Named reference ID for debugging; auto-generated if omitted

acquire
} from '@blac/core';
class
class UserCubit
UserCubit
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
<{
userId: string
userId
: string }, {
userId: string
userId
: string }> {
static
UserCubit.key: (a: {
userId: string;
}) => string
key
= (
a: {
userId: string;
}
a
: {
userId: string
userId
: string }) =>
a: {
userId: string;
}
a
.
userId: string
userId
;
constructor() {
super({
userId: string
userId
: '' });
}
protected
UserCubit.init(args: {
userId: string;
}): void

Called once after construction with the args passed at acquire time, before the first state snapshot is read by any consumer. Override to seed args-derived state (via this.emit(...)) or kick off loads.

init
(
args: {
userId: string;
}
args
: {
userId: string
userId
: string }) {
this.
StateContainer<{ userId: string; }, { userId: string; }, Record<string, never>>.patch(partial: {
userId?: string | 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
({
userId?: string | undefined
userId
:
args: {
userId: string;
}
args
.
userId: string
userId
});
}
UserCubit.setUserId: (id: string) => void
setUserId
= (
id: string
id
: string) => this.
StateContainer<{ userId: string; }, { userId: string; }, Record<string, never>>.patch(partial: {
userId?: string | 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
({
userId?: string | undefined
userId
:
id: string
id
});
}
interface
interface ChannelState
ChannelState
{
ChannelState.messages: string[]
messages
: string[];
}
interface
interface MessageData
MessageData
{
MessageData.userId: string
userId
: string;
MessageData.text: string
text
: string;
}
class
class ChannelCubit
ChannelCubit
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
<
interface ChannelState
ChannelState
> {
constructor() {
super({
ChannelState.messages: string[]
messages
: [] });
}
private
ChannelCubit.ensureUserCubit(userId: string): void
ensureUserCubit
(
userId: string
userId
: string) {
const
const result: {
error: Error;
instance: null;
} | {
error: null;
instance: UserCubit;
}
result
=
borrowSafe<typeof UserCubit>(BlocClass: typeof UserCubit, target?: BorrowTarget<typeof UserCubit> | undefined): {
error: Error;
instance: null;
} | {
error: null;
instance: UserCubit;
}
borrowSafe
(
class UserCubit
UserCubit
, {
args?: {
userId: string;
} | undefined
args
: {
userId: string
userId
} });
if (!
const result: {
error: Error;
instance: null;
} | {
error: null;
instance: UserCubit;
}
result
.
error: Error | null
error
) return; // already exists
// Create on demand, keyed by userId (init seeds the state)
acquire<typeof UserCubit>(BlocClass: typeof UserCubit, opts?: {
args?: {
userId: string;
} | undefined;
refId?: string;
} | undefined): UserCubit

Acquire an instance with ref tracking (ownership semantics). Instance identity is derived purely from args (via the class's static key(args), a structural hash of args, or the default sentinel when there are none).

@paramBlocClass - The StateContainer class constructor

@paramopts.args - Construction/identity args; derives the instance key

@paramopts.refId - Named reference ID for debugging; auto-generated if omitted

acquire
(
class UserCubit
UserCubit
, {
args?: {
userId: string;
} | undefined
args
: {
userId: string
userId
} });
}
ChannelCubit.receiveMessage: (message: MessageData) => void
receiveMessage
= (
message: MessageData
message
:
interface MessageData
MessageData
) => {
this.
ChannelCubit.ensureUserCubit(userId: string): void
ensureUserCubit
(
message: MessageData
message
.
MessageData.userId: string
userId
);
this.
StructuralContainer<ChannelState>.update(fn: (state: ChannelState) => ChannelState): void
update
((
s: ChannelState
s
) => ({
ChannelState.messages: string[]
messages
: [...
s: ChannelState
s
.
ChannelState.messages: string[]
messages
,
message: MessageData
message
.
MessageData.text: string
text
] }));
};
}
  • Instance Management — the registry, ref counting, and ensure/borrow/acquire
  • Inputs and identity — how args and static key resolve the instance a depend() targets
  • Best Practices — when cross-bloc coupling is sound versus a smell
  • Glossary — definitions for registry, ensure, keepAlive, and auto-tracking