Skip to content

Tracking

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 internal function that wraps state in a recording Proxy for each consumer render.

function trackRender<S>(state: S, interner: PathInterner): TrackResult<S>;
ParameterTypeRequiredDescription
stateSyesThe raw state value to wrap. Non-object values (primitives, null) are returned as-is with an empty path set.
internerPathInterneryesThe 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:

RuleEffect
Leaf-only (maximal) recordingReading 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 leafIf 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-circuitReading a primitive, null, or undefined returns it as-is and records the path you took to reach it.
Nested objects/arrays return child proxiesThey record into the same set, and are cached per read, so state.user === state.user within one render.
Iteration coarsensfor..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 leavesThey 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:

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 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
[];
coupon: string | null
coupon
: string | null }> {
get
CartCubit.total: number
total
() {
// During render, this records `items` through the consumer's proxy.
return this.
StructuralContainer<{ items: CartItem[]; coupon: string | null; }>.state: {
items: CartItem[];
coupon: string | null;
}
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);
}
}

So a render can read the getter directly:

function Total() {
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:

function Total() {
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.

When tracking matters (and when it doesn’t)

Section titled “When tracking matters (and when it doesn’t)”

Tracking exists to make re-renders precise. It matters most when:

  • A single container holds many fields and different components read different subsets.
  • A container updates frequently (typing, dragging, streaming) but each tick changes only a slice.

It matters less for a tiny container where every consumer reads everything — there the diff is cheap and the wake set is “everyone” regardless.

Putting it together, one update flows like this:

  1. A method calls emit / update / patch. The container diffs old vs new state and marks the changed paths.
  2. Changes are coalesced per microtask flush, so several synchronous mutations produce one notification (see System Events).
  3. On flush, each consumer’s recorded interest is intersected with the changed paths.
  4. Consumers with a non-empty intersection re-render (React) or have their callback invoked. The rest stay asleep.
  5. 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).

  • Dependency Tracking — the React-facing rules, select, and limitations
  • Performance — splitting readers from writers and avoiding over-reads
  • Mental Model — why proxy tracking beats selectors and memoization
  • watch — observing state outside React

For the full FAQ see Troubleshooting. Below are tracking-specific problems.

Mutating a Map, Set, or Date doesn’t trigger a re-render

Section titled “Mutating a Map, Set, or Date doesn’t trigger a re-render”

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:

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 TagCubit
TagCubit
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
<{
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
TagCubit.addTag: (tag: string) => void
addTag
= (
tag: string
tag
: string) => {
this.
StructuralContainer<{ tags: Set<string>; }>.update(fn: (state: {
tags: Set<string>;
}) => {
tags: Set<string>;
}): void
update
((
s: {
tags: Set<string>;
}
s
) => ({
tags: Set<string>
tags
: new
var Set: SetConstructor
new <string>(iterable?: Iterable<string> | null | undefined) => Set<string> (+1 overload)
Set
([...
s: {
tags: Set<string>;
}
s
.
tags: Set<string>
tags
,
tag: string
tag
]) }));
};
}

Getter read outside render never wakes the component

Section titled “Getter read outside render never wakes the component”

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:

function Total() {
const [, cart] = useBloc(CartCubit);
return <span>${cart.total}</span>;
}

See Getters auto-track during render above and Performance: Getters as computed properties.