Tracking is how BlaC knows which consumers care about a given state change. Instead of re-running every observer on every change, BlaC records exactly which leaves of the state each consumer reads, then wakes only the consumers whose leaves actually moved.
This page explains the mechanism: what gets recorded, how the recording proxy works, how getters fold into it, and how it maps onto re-renders. If you only want the React-facing rules, jump to Dependency Tracking; for the design rationale, see the Mental Model.
Every state container holds an immutable state object. When you emit/update/patch, the container produces a new state and diffs it against the previous one, marking the set of paths that changed — for example user.name or items. A path is a dotted route to a leaf in the state tree.
Separately, each consumer (a useBloc call, a plugin, a manual subscriber) declares an interest: the set of paths it reads. On each change, the container intersects “paths that changed” with “paths this consumer reads.” If the intersection is empty, the consumer stays asleep.
The clever part is that you never write the interest set by hand. BlaC observes which paths you read and builds it for you.
The raw state value to wrap. Non-object values (primitives, null) are returned as-is with an empty path set.
interner
PathInterner
yes
The path interner for the container — interns dotted path strings into compact PathId values.
Returns: a TrackResult<S> — an object { value: S, paths: Set<PathId> } where value is the recording proxy and paths is the live set that grows as properties are read.
Behavior. This is an internal API, not exported from @blac/core. It is called automatically by the React adapter (useBloc) on every render. The proxy records according to these rules:
Rule
Effect
Leaf-only (maximal) recording
Reading a.b.c records just a.b.c, and drops the parent a.b from the set. Two consumers reading sibling leaves (user.name vs user.address) are isolated — one does not wake when the other’s sibling changes.
Reading a whole object keeps it as the leaf
If you read state.user and stop there (no deeper key), user is your leaf and you wake on any change inside it. Read deeper to narrow.
Primitives short-circuit
Reading a primitive, null, or undefined returns it as-is and records the path you took to reach it.
Nested objects/arrays return child proxies
They record into the same set, and are cached per read, so state.user === state.user within one render.
Iteration coarsens
for..of, .map, .find, .reduce record the container path (e.g. users) but not per-index paths; callbacks receive the raw values. So a list component re-renders when the list changes, which is what you want.
Map / Set / Date / class instances are leaves
They are not wrapped. A change to one is detected as a reference change at its own path — so you must replace the whole value (a new Map), not mutate it in place, for the change to be seen.
A getter on the bloc is derived state: it reads other state and computes a value. Getters are the right way to model a value derived from state — a get total() is recomputed on every read and can never drift from items.
In React auto-tracking mode, the bloc returned by useBloc is a per-consumer proxy too. Getter calls are invoked with a tracked this, so this.state inside the getter resolves to the current render’s recording proxy:
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<{
items: CartItem[]
items:
interface CartItem
CartItem[];
coupon: string |null
coupon:string|null }> {
get
CartCubit.total: number
total() {
// During render, this records `items` through the consumer's proxy.
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);
}
}
So a render can read the getter directly:
functionTotal() {
const [,cart] = useBloc(CartCubit);
return<span>{cart.total}</span>;
}
Use select when you want the getter’s return value, rather than its source paths, to gate re-renders:
functionTotal() {
const [,cart] = useBloc(CartCubit, {
select: (state, cart) => [cart.total],
});
return<span>{cart.total}</span>;
}
See Dependency Tracking for the full list of what does and doesn’t register.
A method calls emit / update / patch. The container diffs old vs new state and marks the changed paths.
Changes are coalesced per microtask flush, so several synchronous mutations produce one notification (see System Events).
On flush, each consumer’s recorded interest is intersected with the changed paths.
Consumers with a non-empty intersection re-render (React) or have their callback invoked. The rest stay asleep.
On the next render, the proxy re-records the interest from scratch — so if a component conditionally reads different fields, the tracked set adapts automatically.
The practical takeaway: read exactly the state you render, keep state immutable, and BlaC will re-render the minimum. To drive a specific consumer from an explicit derived-value array instead of render-time reads, use the select option on useBloc (see Dependency Tracking).
Symptom: You call .set(), .add(), or another mutating method on a Map/Set/Date stored in state, but the component doesn’t re-render.
Cause: The tracker wraps plain objects and arrays only. A Map, Set, Date, or class instance is treated as a single leaf — the tracker detects changes as a reference change at that path, never as an internal mutation.
Fix: Replace the whole value with a new instance so the reference 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<{
tags: Set<string>
tags:
interface Set<T>
Set<string> }> {
constructor() {
super({
tags: Set<string>
tags: new
var Set:SetConstructor
new <string>(iterable?:Iterable<string> |null|undefined)=>Set<string> (+1 overload)
Set() });
}
// No-op — same Set reference, nothing tracked
// this.state.tags.add('new'); ← never mutate state directly anyway
// Correct — new Set reference, change detected at the `tags` path
Symptom: Code reads bloc.computedValue (a getter) in an effect, event handler, async callback, or other post-render code and expects that read to subscribe the component.
Cause: The recording proxy is active only while React is evaluating the render body. After commit, getters fall through to live state and record nothing.
Fix: Read the getter during render, or depend on it via select: