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.
The 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 Type → instanceKey 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.
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<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<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.
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.
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.
@param ― callbackfn A function that accepts up to four arguments. The reduce method calls the callbackfn function one time for each element in the array.
@param ― initialValue 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.
this.depend(ShippingCubit) records the dependency (ShippingCubit → instance key) and returns a DepHandle<ShippingCubit>.
Calling .untracked() (or .track()) resolves the instance via ensure() from the registry — creating it if it does not exist yet.
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.
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 })):
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<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<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.
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(
classEditorCubit
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.
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<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<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.
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.
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.
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.
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<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<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.
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.
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.
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<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<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<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<Kextendskeyofany, T> = { [PinK]:T; }
Construct a type with a set of properties K of type T
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.
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.
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';
classPriceBlocextendsCubit<{ amount:number }> {
constructor() {
super({ amount: 100 });
}
}
classCartBlocextendsCubit<{ qty:number }> {
private price =this.depend(PriceBloc);
constructor() {
super({ qty: 2 });
}
gettotal() {
const [priceState] = this.price.track(); // opt in to cross-bloc tracking
returnthis.state.qty* priceState.amount;
}
}
A component that reads cart.total during render auto-subscribes to both CartBlocandPriceBloc — 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
Snapshot recorded by the render-time tracker. Access fields here to record leaf paths (e.g. priceState.amount).
depProxy
dep instance proxy
The 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';
classPriceBlocextendsCubit<{ amount:number }> {
constructor() {
super({ amount: 100 });
}
}
classCartBlocextendsCubit<{ qty:number }> {
private price =this.depend(PriceBloc);
constructor() {
super({ qty: 2 });
}
gettotal() {
const [priceState] = this.price.track();
returnthis.state.qty* priceState.amount;
}
logTotal() {
// Called outside render — .track() returns live values, no subscription registered.
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';
classSrcBlocextendsCubit<{ count:number }> {
constructor() {
super({ count: 5 });
}
getdoubled() {
returnthis.state.count*2;
}
}
classAggBlocextendsCubit<{ offset:number }> {
private src =this.depend(SrcBloc);
constructor() {
super({ offset: 0 });
}
getcomputed() {
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.
returnthis.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.
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.
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.
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).
@param ― BlocClass - The StateContainer class constructor
@param ― opts.args - Construction/identity args; derives the instance key
@param ― opts.refId - Named reference ID for debugging; auto-generated if omitted
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.
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.
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.
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.
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).
@param ― BlocClass - The StateContainer class constructor
@param ― opts.args - Construction/identity args; derives the instance key
@param ― opts.refId - Named reference ID for debugging; auto-generated if omitted