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

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

Returns: a DepHandle<T> with two accessors, both resolving lazily on each call (not at declaration time) — so a dep that was disposed and recreated is transparently picked up on the next call:

  • .untracked(options?) — resolves the instance without subscribing anything. Use for imperative method calls and reads that shouldn’t wake the reader.
  • .track(options?) — resolves the instance and, inside a React render, opts the current consumer into automatic cross-bloc re-renders (see below).

Both accept an optional { args } to resolve a specific keyed instance at call time, overriding defaultArgs.

A getter that calls .track() on the handle opts the current render’s consumer into a subscription on the dependency too, with no second useBloc call:

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
} from '@blac/core';
class
class PriceBloc
PriceBloc
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
<{
amount: number
amount
: number }> {
constructor() {
super({
amount: number
amount
: 100 });
}
}
class
class CartBloc
CartBloc
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
<{
qty: number
qty
: number }> {
private
CartBloc.price: DepHandle<typeof PriceBloc>
price
= this.
StateContainer<{ qty: number; }, void, Record<string, never>>.depend<typeof PriceBloc>(Type: typeof PriceBloc, defaultArgs?: void | undefined): DepHandle<typeof PriceBloc>

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 PriceBloc
PriceBloc
);
constructor() {
super({
qty: number
qty
: 2 });
}
get
CartBloc.total: number
total
() {
const [
const priceState: Readonly<{
amount: number;
}>
priceState
] = this.
CartBloc.price: DepHandle<typeof PriceBloc>
price
.
DepHandle<typeof PriceBloc>.track(options?: DepAccessOptions<typeof PriceBloc> | undefined): [Readonly<{
amount: number;
}>, PriceBloc]
track
(); // opt in to cross-bloc tracking
return this.
StructuralContainer<{ qty: number; }>.state: {
qty: number;
}
state
.
qty: number
qty
*
const priceState: Readonly<{
amount: number;
}>
priceState
.
amount: number
amount
;
}
}
// In a React component:
const [, cart] = useBloc(CartBloc);
return <span>{cart.total}</span>; // re-renders when qty OR price.amount changes

handle.track() returns [trackedState, depProxy]: trackedState is what you read fields off of to record leaf paths, and depProxy is the dep wrapped in a tracking proxy so calling one of its getters records that getter’s own this.state reads too (deep chains work the same way — A.track(B) inside whose getter there’s B.track(C) wakes a reader of A on a C change). A few things worth knowing about .track():

  • Render-aware. Outside a render (an event handler, a plain method) it degrades to a live read with no subscription — safe to call from anywhere.
  • Conditional is fine. Calling it conditionally inside a getter adds/drops the subscription each render, based on whether that render actually took the branch.
  • Mutual deps are cycle-safe. A.track(B) and B.track(A) together do not infinite-loop — the reconciler detects re-entrant tracking within the same render and unions the paths.
  • select mode degrades it. When a consumer uses select on useBloc, .track() behaves like outside-render (live values, no subscription) — select is a manual subscription that opts out of auto-tracking entirely.

Imperative example: calling methods on a dependency

Section titled “Imperative example: calling methods on a dependency”

Dependencies aren’t just for reads — call methods to trigger side effects in another bloc. Method calls don’t need reactivity, so use .untracked():

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
} from '@blac/core';
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.

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
<{
unread: number
unread
: number }> {
constructor() {
super({
unread: number
unread
: 0 });
}
NotificationCubit.incrementUnread: () => void
incrementUnread
= () => 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 }));
}
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.

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
<{
messages: string[]
messages
: string[] }> {
private
ChannelCubit.notifications: DepHandle<typeof NotificationCubit>
notifications
= this.
StateContainer<{ messages: string[]; }, 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({
messages: string[]
messages
: [] });
}
ChannelCubit.receiveMessage: (text: string) => void
receiveMessage
= (
text: string
text
: string) => {
this.
StructuralContainer<{ messages: string[]; }>.update(fn: (state: {
messages: string[];
}) => {
messages: string[];
}): void
update
((
s: {
messages: string[];
}
s
) => ({
messages: string[]
messages
: [...
s: {
messages: string[];
}
s
.
messages: string[]
messages
,
text: string
text
] }));
// A method call needs no reactivity — .untracked() just resolves and calls.
this.
ChannelCubit.notifications: DepHandle<typeof NotificationCubit>
notifications
.
DepHandle<typeof NotificationCubit>.untracked(options?: DepAccessOptions<typeof NotificationCubit> | undefined): NotificationCubit
untracked
().
NotificationCubit.incrementUnread: () => void
incrementUnread
();
};
}

A component that only calls a method on a dependency (never reads its state via .track()) does not re-render when that dependency changes — exactly right for action-only coordination.

Lifecycle: who keeps the dependency alive?

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

depend() resolves through ensure(), and ensure() takes no ref — it creates the instance if needed but does not increment its ref count.

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

This is usually fine — the dependency is typically also mounted somewhere via useBloc. It bites when the dependency is a “pure derived” service nothing renders directly. Two ways to guarantee it survives:

  • @blac({ keepAlive: true }) on the dependency class — the registry never auto-disposes it. See Configuration.
  • Let the cascade do it. When a bloc that created its deps (via ensure) is disposed at zero refs, the registry cascades disposal to those deps if they too are at zero refs and not keepAlive — so a depend()-only graph tears itself down cleanly.

Because resolution is lazy per call, a disposed-and-recreated dependency is never a dangling pointer — the next .untracked()/.track() call simply gets the fresh instance (any state the old one held is gone; use keepAlive if that state must survive).

Avoiding cycles and constructor-time reads

Section titled “Avoiding cycles and constructor-time reads”
ApproachUse when
this.depend(Class)Ongoing dependency, read from getters or multiple methods
ensure(Class)One-off access outside a class; creates if missing
borrow/borrowSafe(Class)One-off access; instance must already exist (throws / returns { error, instance })

See Instance Management for the full registry surface.