# BlaC Documentation Type-safe state management for React with automatic re-render optimization. Comprehensive guide covering core concepts, React integration, plugins, testing, and framework integrations. --- # Async > Model the loading lifecycle as state, guard against overlapping requests, and understand why BlaC does not integrate with React Suspense. Source: /guide/async/ Most real blocs eventually have to fetch something. BlaC has no special async primitive — an async action is just a method that `await`s and emits as it goes. The work is in **modelling the loading lifecycle as state** so the UI can render it, and in guarding against races when requests overlap. This page covers the canonical loading flow, derived loading flags, a reusable "loadable" surface, cancellation, and — importantly — what BlaC does _not_ do with React Suspense. ## Async actions on a Cubit The core pattern: an async method moves state through `loading` -> `success`/`error`, with a request-id guard so a stale response can never overwrite a newer one. Model the lifecycle as a **discriminated union** keyed on `status`. This makes illegal states unrepresentable — you can't have `data` and an `error` at the same time, and TypeScript narrows each branch for you in the view. ```ts twoslash import { Cubit } from '@blac/core'; interface User { id: string; name: string; } declare const api: { fetchUser(id: string): Promise; }; // ---cut--- type UserState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; user: User } | { status: 'error'; message: string }; class UserCubit extends Cubit { // Monotonic counter: every call gets a higher id than the last. private requestId = 0; constructor() { super({ status: 'idle' }); } load = async (id: string) => { const reqId = ++this.requestId; this.emit({ status: 'loading' }); try { const user = await api.fetchUser(id); // Latest-wins: a slower earlier request must not clobber a newer one. if (reqId !== this.requestId) return; this.emit({ status: 'success', user }); } catch (e) { if (reqId !== this.requestId) return; this.emit({ status: 'error', message: String(e) }); } }; } ``` Why `emit` and not `patch` here? Because the union has no shared shape — `loading` has no `user` key, `error` has no `user` key. `patch` deep-merges, which would leave a stale `user` lingering under an `error` status. `emit` replaces wholesale, so each branch is exactly the keys that branch declares. (See [Cubit](/core/cubit) for the replace-vs-merge rule.) ### The request-id guard Two calls to `load('a')` then `load('b')` can resolve in either order — the network doesn't promise FIFO. Without the guard, a slow `'a'` response landing after `'b'` would overwrite the correct result with stale data. The guard is three lines: ```ts twoslash import { Cubit } from '@blac/core'; declare const api: { fetch(id: string): Promise }; type S = { status: 'idle' | 'loading' }; class Demo extends Cubit { private requestId = 0; // ---cut--- load = async (id: string) => { const reqId = ++this.requestId; // claim the latest slot // ... await the request ... if (reqId !== this.requestId) return; // a newer call has started; bail // ... emit the result ... }; // ---cut-after--- } ``` `++this.requestId` claims a fresh id and records it as the current one. Any in-flight call whose `reqId` no longer equals `this.requestId` knows a newer call has superseded it and silently returns before emitting. This is cheaper than wiring an `AbortController` and is enough whenever you only care about the _result_ of the newest request. When you also need to stop the actual network work, reach for [cancellation](#cancellation-and-cleanup). :::tip[One counter per logical operation] `requestId` guards a single operation. If a bloc has several independent async actions (`loadUser`, `loadOrders`), give each its own counter — a shared one would make a `loadOrders` call cancel an in-flight `loadUser`. ::: ## Reading async state in the view Because the state is a discriminated union, the consumer switches on `status` and TypeScript narrows each branch. Auto-tracking records the read of `state.status` (and whatever else each branch touches), so the component re-renders exactly when those paths change. ```tsx function UserCard({ id }: { id: string }) { const [state, user] = useBloc(UserCubit); switch (state.status) { case 'idle': return ; case 'loading': return

Loading…

; case 'error': // `state` is narrowed: `state.message` is available here. return

Failed: {state.message}

; case 'success': // narrowed to the success branch: `state.user` exists. return

{state.user.name}

; } } ``` ## Derived loading flags with getters A union `status` is the source of truth, but views often want a boolean like `isLoading` or a "can I retry?" flag. Derive these with getters rather than storing them — a stored boolean has to be kept in sync with `status` by hand and will eventually drift. ```ts twoslash import { Cubit } from '@blac/core'; interface User { id: string; name: string; } type UserState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; user: User } | { status: 'error'; message: string }; // ---cut--- class UserCubit extends Cubit { constructor() { super({ status: 'idle' }); } get isLoading() { return this.state.status === 'loading'; } get canRetry() { return this.state.status === 'idle' || this.state.status === 'error'; } get user() { return this.state.status === 'success' ? this.state.user : null; } } ``` :::tip[Loading getters track in render] Reading `user.isLoading` directly in render is reactive: the returned bloc is proxied, and `this.state.status` inside the getter records through the current state proxy. Use `select: (_, bloc) => [bloc.isLoading]` only when you want the boolean result to be the re-render boundary. Full rule: [Dependency Tracking](/react/dependency-tracking) and [Performance](/react/performance#pattern-getters-as-computed-properties). ::: ## A reusable loadable surface When several blocs each carry one async resource, the same four-branch union repeats. Factor it into a generic `Loadable` type and a couple of helpers so each bloc stays terse and every consumer narrows the same way. ```ts twoslash export type Loadable = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; message: string }; export const loading = (): Loadable => ({ status: 'loading' }); export const success = (data: T): Loadable => ({ status: 'success', data, }); export const failure = (message: string): Loadable => ({ status: 'error', message, }); ``` A bloc whose entire state _is_ one loadable resource can extend `Cubit>` directly: ```ts twoslash export type Loadable = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; message: string }; declare const loading: () => Loadable; declare const success: (data: T) => Loadable; declare const failure: (message: string) => Loadable; declare const api: { fetchReport(): Promise }; interface Report { id: string; } // ---cut--- import { Cubit } from '@blac/core'; class ReportCubit extends Cubit> { private requestId = 0; constructor() { super({ status: 'idle' }); } load = async () => { const reqId = ++this.requestId; this.emit(loading()); try { const data = await api.fetchReport(); if (reqId !== this.requestId) return; this.emit(success(data)); } catch (e) { if (reqId !== this.requestId) return; this.emit(failure(String(e))); } }; } ``` When the resource is one field among several (a list page with filters, say), nest the loadable on a key instead of making it the whole state, and use `patch` to update that one field — `patch` deep-merges, so the surrounding fields survive: ```ts twoslash export type Loadable = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; message: string }; declare const loading: () => Loadable; declare const success: (data: T) => Loadable; declare const api: { fetchItems(filter: string): Promise }; interface Item { id: string; } // ---cut--- import { Cubit } from '@blac/core'; interface ListState { filter: string; items: Loadable; } class ListCubit extends Cubit { private requestId = 0; constructor() { super({ filter: 'all', items: { status: 'idle' } }); } load = async () => { const reqId = ++this.requestId; // patch only the `items` field; `filter` is untouched. this.patch({ items: loading() }); try { const data = await api.fetchItems(this.state.filter); if (reqId !== this.requestId) return; this.patch({ items: success(data) }); } catch (e) { if (reqId !== this.requestId) return; this.patch({ items: { status: 'error', message: String(e) } }); } }; } ``` ## Suspense BlaC does **not** integrate with React Suspense, and `useBloc` does **not** work with the `use()` hook for data fetching. This is by design, and it's worth being precise about why. `useBloc` never throws a promise to a Suspense boundary or unwraps one via `use()`. There is no code path in `useBloc` that suspends a component. So you don't get a `` boundary catching a BlaC load for free. Instead, **model the loading state explicitly** — which is exactly what the `status` union and the loadable surface above are for. The "fallback" is just the `loading` branch of your switch: ```tsx function ReportView() { const [state, report] = useBloc(ReportCubit, { onMount: (bloc) => bloc.load(), // kick off the fetch when the view appears }); // This switch *is* your Suspense boundary, written out by hand. if (state.status === 'loading' || state.status === 'idle') { return

Loading…

; } if (state.status === 'error') { return (
{state.message}
); } // narrowed to success: `state.data` is the Report. return

{state.data.title}

; } ``` The upside of explicit modelling: error and retry are first-class states, not exceptions you have to catch at a boundary, and the loading UI lives next to the data it's loading. The cost is the `if` ladder — but with a discriminated union it's a few lines and fully type-checked. :::note[Kicking off the load] `onMount` fires after the bloc is acquired, so it's the natural place to start a fetch when the consuming component appears — see [useBloc](/react/use-bloc). For a load that should run once per _instance_ regardless of which component mounts first, start it from the bloc's `init` hook instead; see [Cubit](/core/cubit). ::: ## Cancellation and cleanup The request-id guard stops a stale _result_ from being applied, but the underlying request keeps running. When you also want to abort the in-flight network work — to save bandwidth, or because the bloc is being torn down — pair the guard with an `AbortController`. Pass the controller's `signal` to `fetch`, and abort the previous controller before starting a new request: ```ts twoslash import { Cubit } from '@blac/core'; interface Results { hits: string[]; } type SearchState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; results: Results } | { status: 'error'; message: string }; // ---cut--- class SearchCubit extends Cubit { private requestId = 0; private controller: AbortController | null = null; constructor() { super({ status: 'idle' }); } search = async (query: string) => { const reqId = ++this.requestId; // Abort the previous in-flight request, if any. this.controller?.abort(); const controller = new AbortController(); this.controller = controller; this.emit({ status: 'loading' }); try { const res = await fetch(`/api/search?q=${query}`, { signal: controller.signal, }); const results = (await res.json()) as Results; if (reqId !== this.requestId) return; this.emit({ status: 'success', results }); } catch (e) { // An abort surfaces as an AbortError; it isn't a real failure. if (e instanceof DOMException && e.name === 'AbortError') return; if (reqId !== this.requestId) return; this.emit({ status: 'error', message: String(e) }); } }; } ``` Two guards now run together: `AbortController` stops the request (and makes `fetch` reject with an `AbortError` you ignore), while the `requestId` check still protects the emit in case a response was already in transit when the new call started. ### Aborting on disposal A request in flight when the bloc is disposed will try to `emit` on a dead container. That no longer throws — it's a no-op (with a dev-only console warning) — but the update is silently dropped, which wastes the in-flight work. Wire `onSystemEvent('dispose', …)` to abort the controller so the request unwinds cleanly: ```ts twoslash import { Cubit } from '@blac/core'; type SearchState = { status: 'idle' | 'loading' }; // ---cut--- class SearchCubit extends Cubit { private controller: AbortController | null = null; constructor() { super({ status: 'idle' }); this.onSystemEvent('dispose', () => { this.controller?.abort(); }); } } ``` `onSystemEvent('dispose', …)` fires when the instance is being torn down (its ref count hit zero, or it was cleared). Aborting there cancels the request, and the `AbortError` branch swallows the rejection — no emit reaches the disposed container. See [System Events](/core/system-events) for the full lifecycle. :::tip[Guard `$blac.disposed` if you skip the abort] If you don't use an `AbortController`, an async action that resumes after disposal can still hit a disposed bloc. Either check `this.$blac.disposed` before emitting, or — cleaner — abort on `dispose` as above so the request never resolves into an emit at all. ::: ## See also - [Cubit](/core/cubit) — `emit` / `patch` / `update`, getters, and the `init` hook - [useBloc](/react/use-bloc) — `onMount`, `select`, and how re-renders are scoped - [System Events](/core/system-events) — the `dispose` and `hydrationChanged` lifecycle hooks - [Core Concepts](/guide/concepts/) — the vocabulary and model behind these APIs --- # Core Concepts > A quick conceptual tour of state containers, the registry, instance modes, dependency tracking, and plugins. Source: /guide/concepts/ This is the quick conceptual tour — one screen per idea, enough to start making good decisions about how to structure your state. For the deep "why it works this way" (the reactivity model, the per-consumer tracker, batching and ref-counting rationale, and honest comparisons to other libraries), read the [Mental Model](/guide/mental-model). ## State Containers A state container is a class that holds a typed state value and notifies listeners when it changes. Think of it like a mini-store scoped to one concern. An `AuthCubit` holds auth state. A `CartCubit` holds cart state. Each is a self-contained unit with its own state type, methods, and lifecycle. **Cubit** is the concrete class you'll extend. It gives you three ways to change state: ```ts class AuthCubit extends Cubit<{ user: User | null; loading: boolean }> { constructor() { super({ user: null, loading: false }); // initial state } login = async (credentials: Credentials) => { this.patch({ loading: true }); // merge partial changes const user = await api.login(credentials); this.emit({ user, loading: false }); // replace entire state }; logout = () => { this.update((s) => ({ ...s, user: null })); // derive from current }; } ``` State containers are framework-agnostic — they work without React. You can instantiate them in a test, call methods, and assert on `state` directly. No DOM, no hooks, no providers needed. ## Registry The registry is a global singleton that manages state container instances. When you call `useBloc(CounterCubit)` in two different components, they both get the **same** `CounterCubit` instance. The registry makes this happen. It maps each class (and optional instance key) to a single instance, plus a ref count tracking how many consumers are using it: ```text Registry ├── CounterCubit (default) → instance, refCount: 2 ← two components ├── AuthCubit (default) → instance, refCount: 1 └── EditorCubit ("doc-42") → instance, refCount: 3 ``` ### Ref counting Every `useBloc` call increments the ref count on mount and decrements it on unmount. When the count hits zero, the instance is **automatically disposed** — its resources are cleaned up and it's removed from the registry. This means you don't need to worry about memory leaks from forgotten state containers. If you want an instance to survive even when nothing is using it, mark it with `@blac({ keepAlive: true })`. ### Registry functions In React, `useBloc` handles the registry for you. Outside React (tests, scripts, server-side), you interact with it directly. The headline verbs: | Function | Creates? | Ref count | Use when | | ---------------- | -------- | --------- | ----------------------------------------------- | | `acquire(Class)` | Yes | +1 | You own this reference (must `release` later) | | `ensure(Class)` | Yes | No change | You need the instance but don't own a reference | | `borrow(Class)` | No | No change | Instance must already exist (throws if not) | | `release(Class)` | No | -1 | Done with your reference | This is the quick version. The complete function table (including `borrowSafe`, `getRefCount`, `clear`, and the keying rules) lives in [Instance Management](/core/instance-management). ## Instance Modes ### Shared (default) All calls to `useBloc(CounterCubit)` return the same instance. This is the common case for app-wide state like auth, theme, or cart. ### Named Put the distinguishing value in `args` (and select it with `static key`) to get a distinct instance per key. Different args resolve to different instances. ```tsx useBloc(EditorCubit, { args: { docId: 'doc-42' } }); ``` ### Keep alive With `@blac({ keepAlive: true })`, the instance survives even when all components using it unmount. It persists for the lifetime of the app. ## Inputs: args and deps Components rarely need a blank container — they need one seeded with data. BlaC keeps that data in two separate lanes so a shared instance with many consumers never races: - **`args`** — serializable data that _identifies_ an instance (e.g. a document id). Same args resolve to the same instance; identity is derived from args alone. - **`deps`** — non-serializable handles (callbacks, services) injected per consumer, never used for identity. This is just the teaser; the full model, the precedence rules, and the failure modes live in [Inputs](/guide/inputs). ## Dependency Tracking This is BlaC's key performance feature — the quick tour here, with the full mechanism and its edge cases in [Dependency Tracking](/react/dependency-tracking). When you call `useBloc`, the returned `state` is wrapped in a Proxy that records which properties your component actually reads during render: ```tsx function UserName() { const [state] = useBloc(UserCubit); return {state.name}; // only 'name' is tracked } ``` If `state.email` changes but `state.name` doesn't, this component **won't re-render**. This happens automatically — no selectors, no `useMemo`, no `React.memo`. See it live: `CountReader` tracks `state.count`; `LabelReader` tracks `state.label`. Each component's render badge increments only when its own field changes. The tracking also works for: - Nested properties: `state.user.profile.name` (records the leaf path) - Array access: `state.items.length`, `state.items[0]` Getter reads participate too: the `bloc` returned by `useBloc` is proxied, so reading `bloc.total` during render records the `this.state` paths the getter touches. Use `select` when you want a getter's return value, rather than its source paths, to decide re-renders. See [Dependency Tracking](/react/dependency-tracking). There are two tracking modes, chosen by whether you pass a `select` option — there is no separate on/off flag: | Mode | How | Best for | | --------------------------- | -------------------------------------------------------- | ------------------------------------- | | **Auto-tracking** (default) | Proxy records property access during render | Most components | | **`select`** (manual) | You return an array; re-render when it changes per-index | Complex conditions or computed values | A component that only calls methods and never displays state needs no special option: do not read from `state`, and auto-tracking records nothing to wake it. See [Dependency Tracking](/react/dependency-tracking) for the full story, including the conditional-read caveat. ## Plugins Plugins observe lifecycle events across all state containers. They receive callbacks for instance creation, state changes, and disposal. ```ts const plugin: BlacPlugin = { name: 'my-plugin', version: '1.0.0', onStateChange(ctx, prev, next, paths) { ... }, }; getPluginManager().install(plugin); ``` Official plugins: [Logging](/plugins/logging), [DevTools](/plugins/devtools), [Persistence](/plugins/persistence). ## Glossary ## What's next? - [Mental Model](/guide/mental-model) — The deep "why" behind everything on this page - [Inputs](/guide/inputs) — args, deps, and instance identity in full - [Cubit](/core/cubit) — Full Cubit API reference - [useBloc](/react/use-bloc) — Hook options and tracking modes ## See also - [Mental Model](/guide/mental-model) — the deep version of this tour - [Instance Management](/core/instance-management) — the complete registry function reference - [Dependency Tracking](/react/dependency-tracking) — auto-tracking in depth --- # Quick Start > Install BlaC and build your first Cubit-powered React component. Source: /guide/getting-started/ :::caution[Beta] BlaC v2 is in beta. Breaking changes may ship in patch releases, so pin exact versions and check the changelog before upgrading. ::: ## Installation ```bash pnpm add @blac/core @blac/react ``` ```bash npm install @blac/core @blac/react ``` ```bash yarn add @blac/core @blac/react ``` BlaC requires React 18+ and TypeScript is strongly recommended.
Recommended `tsconfig.json` BlaC works with a standard strict React setup. The `@blac()` decorator (used for `keepAlive`, `static key`, and other options) works as either a legacy (`experimentalDecorators`) or a TC39/stage-3 decorator — enable decorator support in your tsconfig to use the `@blac(...)` syntax. Or skip decorators entirely with the functional form — `blac({ ... })(class extends Cubit { ... })` — which needs no extra compiler flags. ```jsonc { "compilerOptions": { "target": "ESNext", "jsx": "react-jsx", "strict": true, "useDefineForClassFields": true, "experimentalDecorators": true, // only if you use @blac(...) decorator syntax }, } ```
## Step 1: Define a Cubit A Cubit is a class that holds state and exposes methods to change it. ```ts twoslash import { Cubit } from '@blac/core'; class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } increment = () => this.emit({ count: this.state.count + 1 }); decrement = () => this.update((s) => ({ count: s.count - 1 })); reset = () => this.patch({ count: 0 }); } ``` Three ways to change state: | Method | What it does | When to use | | ---------------- | --------------------------------------------- | ------------------------------------------------ | | `emit(newState)` | Replace the entire state | You have the full new state ready | | `update(fn)` | Derive new state from current | You need to read current state first | | `patch(partial)` | Deep-merge partial changes (`DeepPartial`) | You want to update some fields and keep the rest | :::caution[`emit` and `update` replace; only `patch` merges] `emit(next)` and `update(fn)` set state to _exactly_ what you return — any key you forget to include is dropped. `patch(partial)` deep-merges, so the keys you omit survive. The `increment` above is fine because `count` is the only key; for multi-field state, either spread the previous state (`update((s) => ({ ...s, count: s.count + 1 }))`) or use `patch`. ::: :::tip[Define methods as arrow-function fields] Every method here is an arrow-function class field (`increment = () => …`), not a regular method. This binds `this` to the instance, so `counter.increment` keeps working when passed straight to `onClick`. A regular method (`increment() { … }`) loses `this` once detached from the instance. ::: ## Step 2: Use it in React The `useBloc` hook connects your component to a Cubit. ```tsx import { useBloc } from '@blac/react'; function Counter() { const [state, counter] = useBloc(CounterCubit); return (

Count: {state.count}

); } ``` `useBloc` returns a tuple: - `state` — the current state snapshot (tracked for re-renders) - `counter` — the Cubit instance (call methods on it) ## Step 3: Share state across components By default, every component that calls `useBloc(CounterCubit)` gets the **same instance**. State is automatically shared. ```tsx function CounterDisplay() { const [state] = useBloc(CounterCubit); return

Count: {state.count}

; } function CounterControls() { const [, counter] = useBloc(CounterCubit); return ; } function App() { return ( <> ); } ``` When `CounterControls` calls `increment`, `CounterDisplay` re-renders with the new count. No providers, no context, no prop drilling. ## Step 4: Add business logic Keep logic in the class, not in the component. ```ts class TodoCubit extends Cubit<{ items: string[]; input: string }> { constructor() { super({ items: [], input: '' }); } setInput = (value: string) => this.patch({ input: value }); addTodo = () => { const trimmed = this.state.input.trim(); if (!trimmed) return; // emit/update REPLACE state, so list every key you want to keep. this.update((s) => ({ items: [...s.items, trimmed], input: '' })); }; removeTodo = (index: number) => { // patch deep-merges, so we only mention the key we change. this.patch({ items: this.state.items.filter((_, i) => i !== index) }); }; get isEmpty() { return this.state.items.length === 0; } } ``` Notice the two write styles: `addTodo` uses `update` and lists _both_ keys (replacing the whole state), while `removeTodo` uses `patch` and mentions _only_ `items` (merging into the rest). Both are correct — the difference is exactly the replace-vs-merge rule from Step 1. Getters like `isEmpty` derive a value on every read, so they can never drift from `items`. When a component reads `todo.isEmpty` during render, auto-tracking records the getter's underlying `this.state.items.length` read through the proxy. Use `select` only when you want the getter's return value to be the explicit re-render boundary. The full rule is in [Dependency Tracking](/react/dependency-tracking). For async work (loading flags, fetches, request guards), see [Async](/guide/async). :::caution[Component not re-rendering?] The two most common first-time causes: 1. **Reading raw state off the bloc instead of the `state` proxy.** Auto-tracking records reads on the destructured `state` value and on getters read from the returned `bloc` proxy during render. It does not track `counter.state.count`, because that reads raw state directly. Read through `state` in render, or expose a getter that reads `this.state`. 2. **Expecting `emit`/`update` to merge.** They _replace_ the whole state, so any key you omit is dropped. Use `patch` to merge, or spread the previous state. The full symptom list is in [Troubleshooting & FAQ](/guide/troubleshooting). ::: ## Step 5: Fetch something An async action is just a method that `await`s and emits as it goes. Model the lifecycle as a `status` union so the view can render loading and error states, and use a request-id guard so a slow response can never overwrite a newer one. ```ts twoslash import { Cubit } from '@blac/core'; interface User { id: string; name: string; } declare const api: { fetchUser(id: string): Promise }; // ---cut--- type UserState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; user: User } | { status: 'error'; message: string }; class UserCubit extends Cubit { private requestId = 0; constructor() { super({ status: 'idle' }); } load = async (id: string) => { const reqId = ++this.requestId; // claim the latest slot this.emit({ status: 'loading' }); try { const user = await api.fetchUser(id); if (reqId !== this.requestId) return; // a newer call won; bail this.emit({ status: 'success', user }); } catch (e) { if (reqId !== this.requestId) return; this.emit({ status: 'error', message: String(e) }); } }; } ``` The view switches on `state.status` and TypeScript narrows each branch. Derived loading flags, the loadable surface, cancellation with `AbortController`, and why BlaC does _not_ use React Suspense are all in the [Async guide](/guide/async). ## What just happened? When you call `useBloc(CounterCubit)`: 1. The **registry** checks if an instance of `CounterCubit` already exists 2. If not, it creates one and stores it. If yes, it returns the existing one 3. A **ref count** is incremented (tracking how many components use this instance) 4. The hook subscribes to state changes using **auto-tracking** — a Proxy wraps the state and records which properties your render function accesses 5. On re-render, only changes to those specific properties trigger an update 6. When the component unmounts, the ref count decrements. At zero, the instance is disposed Each of these steps has a "why" worth understanding once your app grows — why a proxy beats selectors, why disposal is automatic, why updates batch on a microtask. That deep version lives in the [Mental Model](/guide/mental-model). ## What's next? - [Core Concepts](/guide/concepts) — A quick tour of registry, tracking, and lifecycle - [Mental Model](/guide/mental-model) — The deep version of "what just happened?" - [Cubit](/core/cubit) — Full Cubit API - [useBloc](/react/use-bloc) — Hook options and tracking modes ## See also - [Core Concepts](/guide/concepts) — the quick conceptual tour - [Cubit](/core/cubit) — `emit` / `update` / `patch` in full - [useBloc](/react/use-bloc) — every hook option and both tracking modes - [DevTools](/plugins/devtools) — inspect state and re-renders in real time --- # Passing Inputs to Blocs > The three input lanes — args, deps, and events — and the identity model that makes shared bloc instances safe. Source: /guide/inputs/ Blocs sometimes need external data — a user ID to load, an endpoint URL, a DOM ref for a canvas. Passing that data safely is trickier than it looks, because multiple components can share one instance and each wants to set something on it. :::note[Why this needs its own model] A naive answer is "pass one `props`-style object into the bloc." That breaks the moment two components share an instance: if both write the same input, whoever rendered last wins, and the value flickers on every render — an _override race_. The fix is to recognise that "inputs" is really **three different needs**, each with its own lifetime and its own answer. Sorting an input into the right lane is what makes shared instances safe. ::: The three lanes — `args`, `deps`, and events — and the [identity model](#identity-model-and-precedence) below are the whole story. For the broader _why_ behind shared, ref-counted instances see the [Mental Model](/guide/mental-model),. ## The three input lanes | Lane | Purpose | Keys identity? | Lifetime | Example | | ---------- | ------------------------------------------- | ----------------------------------------- | ------------------------- | --------------------------------------- | | **`args`** | Typed creation/config data | **Yes** (structural hash or `static key`) | Set once via `init(args)` | `userId`, `endpoint`, `filters` | | **`deps`** | Non-serializable handles | **Never** | Live, per-consumer merged | `inputRef`, stable callback, `emblaApi` | | **events** | Values that change over the instance's life | N/A | Called from effects | `cubit.slidesChanged(v)` | These map onto a familiar React split: `args` is like `defaultValue` (set at birth, identity-bearing), `events` is like `value`/`onChange` (a live channel owned by one component), and `deps` is the side channel for things that can't be serialized at all. --- ## `args`: construction data that keys the instance When a bloc declares an `Args` type, `useBloc` requires you to pass `args`. They are: - Forwarded to the bloc's `init(args)` method **once, synchronously, before the first state snapshot** — so initial state is correct on the first render, no flash. - Used to **derive the instance key** — different args ⇒ different instance. Same args ⇒ same instance. - A **type error** to omit when declared, or to pass when `Args` is `void`. ```ts class UserCardCubit extends Cubit { protected init(args: { userId: string }) { // called once by the framework at creation, before the first snapshot } // Side effects (fetch/subscribe/timer) belong in onActivate, not init — // it only fires once something actually owns the instance. See Cubit's // onActivate / onDeactivate docs. protected onActivate(signal: AbortSignal) { void this.loadUser(this.args!.userId); } } ``` ```tsx // args is required and type-checked const [state] = useBloc(UserCardCubit, { args: { userId } }); ``` **Why this eliminates the override race:** each distinct `userId` produces a distinct instance. Multiple components rendering the same `userId` share one instance and their `args` are by definition identical. There is nothing to race over. ### Identity keying By default, identity is the **structural hash of all args** (the same principle as `atomFamily` and TanStack Query's `queryKey`). This is safe because args must be JSON-serializable — no refs or callbacks can accidentally destabilize the hash. Override identity with a `static key` on the class when only a subset of args should key the instance, or when you want a human-readable key: ```ts class DocumentCubit extends Cubit< DocState, { docId: string; readonly: boolean } > { static key = (args: DocumentCubit['args']) => args.docId; // `readonly` is config that rides along but does NOT fork instances } ``` `static key` is declared **once on the class**, not repeated at every call site. It reads as a plain statement of what distinguishes one `DocumentCubit` from another. You can also supply `static key` via the `blac()` decorator/config function: ```ts const DocumentCubit = blac({ key: (args) => args.docId })( class extends Cubit { ... } ); ``` See [Configuration](/core/configuration) for the rest of the `blac()` options (`keepAlive`, `equality`, `excludeFromDevTools`) and [Core Concepts](/guide/concepts/) for how `args` and `static key` relate. ### Per-component private instances To give each mount its own instance — disposed on unmount, never shared — include a **per-mount unique value in `args`** and declare a `static key` that uses it. The idiomatic source of a stable-per-mount value is React's `useId()`: ```tsx // private instance, own lifecycle, seeded with args class FileUploadCubit extends Cubit< UploadState, { endpoint: string; _id: string } > { static key = (a: FileUploadCubit['args']) => a._id; } const _id = useId(); const [state, cubit] = useBloc(FileUploadCubit, { args: { ...options, _id } }); ``` Because `useId()` returns a different value per component instance (and the same value across that component's re-renders), the derived key is unique per mount, so each mount gets a private bloc that lives and dies with it. The other args (`endpoint`) still seed `init(args)` — they ride along without forking instances because only `_id` keys identity. :::caution[No `instanceId` or `autoInstance` option] You might reach for `useBloc(C, { args, instanceId })` or `useBloc(C, { args, autoInstance: true })`. **Neither option exists** — `useBloc` accepts only `args`, `select`, `onMount`, and `onUnmount`. The per-mount idiom is the synthetic-args `static key` shown above. See [useBloc](/react/use-bloc) for the complete option list. ::: ### Args must be serializable Args participate in identity hashing. Refs, callbacks, DOM elements, and class instances cannot go in `args` — they belong in the `deps` lane (below). Passing non-serializable values produces an unstable hash (a new key every render) which will cause a new instance on every render. --- ## `deps`: non-serializable handles Some things are genuinely non-serializable: a `useRef` container, a `useCallback`-stabilized handler, an Embla API instance handed in from outside. These live in the bloc's third type parameter — its `Deps` — and obey these rules: - **Never key identity** — different refs don't fork the instance. - **Per-consumer merged** — each component contributes its own slice; the bloc sees the union. - **Read lazily** — via `this.deps.x` at action time, may be `undefined`. Always guard. - **Applied post-commit** — never during render; no mid-render mutation. The bloc side is declarative: declare the `Deps` shape and read `this.deps.x` lazily. ```ts import type { RefObject } from 'react'; class FileUploadCubit extends Cubit< UploadState, { endpoint: string }, // args → keys identity { inputRef?: RefObject } // deps → never keyed, read lazily > { protected init(args: { endpoint: string }) { this.endpoint = args.endpoint; } openPicker() { this.deps.inputRef?.current?.click?.(); // guard for absence } } ``` ### Wiring deps from a component :::caution[`deps` is not a `useBloc` option] There is no `deps:` key on `useBloc`. A component contributes its slice by calling `useBlocDeps(bloc, slice)`, exported alongside `useBloc`: ::: ```tsx import { useRef } from 'react'; import { useBloc, useBlocDeps } from '@blac/react'; function UploadButton({ endpoint }: { endpoint: string }) { const inputRef = useRef(null); // same endpoint → same instance, regardless of which ref is passed const [state, cubit] = useBloc(FileUploadCubit, { args: { endpoint } }); useBlocDeps(cubit, { inputRef }); // contribute this consumer's slice return ; } ``` Each calling component is one owner: `useBlocDeps` tags its slice internally (with a stable per-mount id) so the engine knows which consumer wrote it, shallow-merges it into the bloc's merged `deps` view, and withdraws exactly that slice automatically on unmount. No dependency array is needed — the hook applies the slice on every commit and the core shallow-compares, so an unchanged slice is a no-op; still pass identity-stable values (`useMemo` / `useCallback`), since a new function identity counts as a change.
Why an effect, not a render-time call Deps must be applied _after_ commit so a freshly mounted `ref.current` is populated and so mid-render mutation never happens. `useBlocDeps` applies in an effect, post-commit; its cleanup runs before the consumer's `useBloc` releases its ref, so the slice is withdrawn while the bloc is still alive.
### Multi-consumer merge Different components can contribute different slices of deps to the same instance. The rules are simple: 1. Each consumer's keys are shallow-merged into `bloc.deps`. 2. When a consumer unmounts, its keys are withdrawn; other consumers' keys are untouched. 3. If two consumers set the same key to different values, a dev warning fires and the last write wins — a design smell, since one key should have one owner. ```tsx // Component A owns inputRef useBlocDeps(cubit, { inputRef }); // Component B owns onSubmit useBlocDeps(cubit, { onSubmit }); // bloc.deps === { inputRef, onSubmit } — assembled from both consumers ``` ### `onDepsChanged` — reacting when a handle arrives For handles that need to trigger initialization (a canvas, a rich-text-editor controller), implement `onDepsChanged` on the bloc. It fires after each deps merge, receives `(next, prev)`, and lets the bloc diff which handle changed: ```ts class CanvasRendererCubit extends Cubit< RenderState, { sceneId: string }, { canvas?: HTMLCanvasElement; controller?: RteController } > { onDepsChanged(next: this['deps'], prev: this['deps']) { if (next.canvas && next.canvas !== prev.canvas) { this.initRenderer(next.canvas); // canvas arrived or changed → init GPU loop } if (!next.canvas && prev.canvas) { this.disposeRenderer(); // canvas unmounted → tear down } if (next.controller !== prev.controller) { this.bindController(next.controller); } } } ``` This is the canonical pattern for "wait for a ref, then initialize." Blocs that don't declare `onDepsChanged` just read `this.deps.x` lazily when an action runs. ### The callback staleness gotcha An inline callback (`onComplete={() => doThing()}`) gets a new function identity every render. If you capture it once in `deps`, the bloc holds the first closure forever. Prefer these patterns, best first: 1. **Callback inversion (recommended):** expose state; let the component call its own fresh callback in a `useEffect`. ```tsx const [{ uploadedId }] = useBloc(FileUploadCubit, { args: { endpoint } }); useEffect(() => { if (uploadedId) onComplete(uploadedId); // always the current closure }, [uploadedId, onComplete]); ``` 2. **Stabilize with `useCallback`** before contributing it as a dep. 3. **Push via a bloc method from an effect** — `useEffect(() => cubit.setOnComplete(cb), [cb])`. :::caution[Common mistakes] - **Non-serializable value in `args`** — a ref, callback, DOM node, or class instance makes the structural hash unstable, so you get a _new instance every render_. Put it in deps instead. (The structural-key hasher throws in dev if it sees a function in `args`.) - **Capturing an inline callback in deps** — `{ onComplete: () => doThing() }` freezes the first render's closure. Stabilize with `useCallback`, or invert the callback (option 1 above). - **Two consumers writing the same deps key** — last write wins and the value flickers. Give each shared value exactly one owning component. - **Reaching for `deps:`, `instanceId:`, or `autoInstance:` as `useBloc` options** — none exist. Wire deps with `useBlocDeps`; key per-mount instances with a synthetic `args` value + `static key` (e.g. `args: { _id: useId() }`). ::: --- ## Events: live data from one owning effect For values that **genuinely change** over a shared instance's life — a `slides` array, a selected `theme` — call an ordinary bloc method from a single effect. This is the XState/flutter_bloc model: no `inputs` slot, no new concept, just a method call after commit. ```ts class CarouselCubit extends Cubit { slidesChanged(slides: Slide[]) { this.patch({ slides, total: slides.length }); } } ``` ```tsx function Carousel({ slides }: { slides: Slide[] }) { const [state, cubit] = useBloc(CarouselCubit, { args: { id } }); // ONE component owns syncing this live value useEffect(() => { cubit.slidesChanged(slides); }, [cubit, slides]); return /* ... */; } ``` **Convention:** one component owns syncing any given live value. Two components both calling `cubit.slidesChanged` from their own effects on the same instance is a design smell (and rare — keyed `args` usually route such cases to distinct instances). --- ## Identity model and precedence When `useBloc` resolves which instance to connect to, it consults sources in this order — **the first that yields a key wins**. This is the canonical ordering; [useBloc](/react/use-bloc) restates the same list. | Priority | Source | Resolved key | | -------- | -------------------------------- | ----------------------------------------------------------------------------- | | 1 | own `args` on the `useBloc` call | `static key(args)` if declared, else the structural hash of `args` | | 2 | `` context `args` | same resolution applied to inherited args, when the call passes no own `args` | | 3 | `'default'` | singleton fallback — no `args`, no `static key`, no provider | `args`-derived identity is the **only** idiom for per-value instances. For an identity that can't be derived from real data — anonymous, opaque, externally managed, or deliberately per-mount — synthesize one by adding a value to `args` (e.g. `_id: useId()`) and keying on it with `static key`. ### Decision matrix Two questions decide everything: **is this input identity (set once) or live (changes over the instance's life)?** and **is the instance private to one component or shared?** | | Input defines identity / set once | Input is live and changing | | ---------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | **Private to one component** | `useBloc(C, { args: { ...data, _id: useId() } })` + `static key` on `_id` — own instance, seeded from args | synthetic per-mount `args` + call `cubit.xChanged(v)` from that component's effect | | **Shared across consumers** | `useBloc(C, { args })` — args key the instance; override race impossible | call `cubit.xChanged(v)` from the **one owning component's** effect | Non-serializable handles (refs, callbacks, controllers) sit _outside_ this matrix entirely — they go through the [deps lane](#deps-non-serializable-handles) and never touch identity. --- ## A note on naming The `select` option (the per-consumer re-render selector) is unrelated to the `deps` lane on this page, despite both sounding "dependency"-ish. `select` opts a consumer _out_ of auto-tracking; `deps` feeds _non-serializable handles_ into the bloc. The names are deliberately distinct to keep the two concepts from colliding. ## See also - [Core Concepts](/guide/concepts/) — the vocabulary these lanes are built on - [Mental Model](/guide/mental-model) — _why_ instances are shared and ref-counted - [useBloc](/react/use-bloc) — the complete option list and identity precedence (canonical) - [Cubit](/core/cubit) — `init(args)`, `onDepsChanged`, and the mutation API --- # How BlaC Works Internally > Rebuild BlaC's reactivity engine from the ground up in four stages — the dirty channel, the path interner, the recording proxy, the observed skeleton, and cross-bloc dependencies. Source: /guide/internals/ This chapter rebuilds BlaC's reactivity engine from the ground up, one stage at a time. By the end you will be able to point at every moving part — the dirty channel, the path interner, the recording proxy, the observed skeleton, and the cross-bloc dependency layer — and say exactly what it does and why it has to. If you only want the rules for writing components, read [Tracking](/core/tracked) and [Dependency Tracking](/react/dependency-tracking). This page is for when you want the _machine_. It is grounded directly in the source: the engine lives in `@dirtytalk/engine`, the path machinery in `@dirtytalk/structural`, and BlaC's lifecycle layer in `@blac/core`'s `StateContainer`. The companion design narrative is the [Mental Model](/guide/mental-model); the distilled standalone version is [DirtyTalk](/dirtytalk/). We build in four stages, each strictly on top of the last: ``` Stage 1 state + listeners "something changed — wake everyone" │ (DirtyChannel, coalesced flush) ▼ Stage 2 paths "what changed — as interned integers" │ (PathInterner, PathSet, intersection) ▼ Stage 3 the skeleton "who reads what — diff once, fan out cheap" │ (trackRender proxy + observed skeleton) ▼ Stage 4 cross-bloc deps "one bloc leans on another" (StateContainer.depend / registry) ``` --- ## Stage 1 — State and listeners Start with the smallest honest model: a value, a set of listeners, and a way to say "I changed." Every reactive store has this core. The naive version notifies every listener synchronously on every change. BlaC's real notification core is `DirtyChannel` from `@dirtytalk/engine`, but we approach it from the outside in. From the outside, that contract is what `Cubit` exposes: a value behind `state`, mutated by `emit` / `update`, with `watch` (or the lower-level `channel.subscribe`) firing on every coalesced flush. This block type-checks against the real `@blac/core` surface: ```ts twoslash import { Cubit, watch, ensure } from '@blac/core'; class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } increment() { // Immutable replacement. Several synchronous emits coalesce into ONE flush. this.emit({ count: this.state.count + 1 }); } } // watch wakes on every flush and hands you the instance with the latest state. const stop = watch(CounterCubit, (counter) => console.log('woke at', counter.state.count), ); const c = ensure(CounterCubit); c.increment(); c.increment(); // two emits, same tick → callback fires once with count: 2 stop(); ``` Underneath, `watch` is a thin bridge over the real notification core: `DirtyChannel` from `@dirtytalk/engine`. It is generic over a `Region` — an opaque "what changed" value governed by a `Space` algebra (`empty`, `isEmpty`, `union`, `intersects`). Two properties make it more than a plain `EventEmitter`. Modeled with a trivial boolean `Region` where "dirty" is just `true` (illustrative — `@dirtytalk/engine` is the engine package, not a docs dependency, so this block is shown rather than type-checked): ```ts import { DirtyChannel, SyncScheduler, type Space } from '@dirtytalk/engine'; // The simplest possible Region: a boolean. `true` = "something changed". const BoolSpace: Space = { empty: () => false, isEmpty: (r) => r === false, union: (a, b) => a || b, // Any non-empty dirty wakes any interested subscriber. intersects: (interest, dirty) => interest && dirty, }; class Counter { private _state = 0; // SyncScheduler flushes inline (production uses MicrotaskScheduler). private channel = new DirtyChannel(BoolSpace, new SyncScheduler()); get state() { return this._state; } listen(cb: () => void) { return this.channel.subscribe( () => true, // interest: "I care about everything" () => cb(), ); } increment() { this._state += 1; this.channel.mark(true); // "something changed" } } ``` The two load-bearing properties of `DirtyChannel`: 1. **Marks accumulate, flushes coalesce.** `mark(region)` unions the region into an accumulator and asks the scheduler to flush once. Several synchronous `mark`s collapse into a single flush — so three `emit`s in one tick wake each listener at most once. 2. **Interests are thunks evaluated at flush time.** A subscriber registers `subscribe(() => interest, cb)`. On flush the channel evaluates the interest thunk, tests `space.intersects(interest, dirty)`, and calls `cb` only on a hit. (This is the seam Stage 2 and 3 exploit; here the region is just "everything.") ``` Stage 1: mark → flush → wake everyone increment() increment() (same tick) │ │ ▼ ▼ mark(true) mark(true) union → still `true` └─────┬──────┘ ▼ scheduler.request(flush) coalesced: scheduled once │ ▼ flush: dirty = true ───────────► every subscriber whose interest intersects → cb() ``` This is everything a `Cubit` does at the listener level. The `watch(BlocClass, cb)` helper is exactly this: it bridges every channel flush to a callback that wakes on any change, regardless of what changed, applied from outside React. The flush is microtask-coalesced, which is why several synchronous `emit`s produce a single notification. What's missing: "wake everyone" is wasteful. A container with twenty fields wakes a listener that reads one of them on all twenty kinds of change. Stage 2 makes "what changed" precise. --- ## Stage 2 — Paths Replace the boolean region with a **set of changed paths**. A path is a dotted route to a leaf in the state tree: `user.name`, `items`, `user.address.city`. A mutation marks the paths it touched; a listener declares the paths it reads; the channel wakes a listener only when those two sets intersect. Comparing and unioning dotted strings on every mutation would be wasteful, so every path is **interned** into a small integer `PathId` once. `PathInterner` (in `@dirtytalk/structural`, used internally by every BlaC container) is a bidirectional `string ↔ PathId` table: `intern(path)` returns a stable id (the next sequential integer, starting at `0`), idempotently; `lookup(id)` reverses it. ```ts import { PathInterner } from '@dirtytalk/structural'; const interner = new PathInterner(); interner.intern('user.name'); // 0 interner.intern('user.email'); // 1 interner.intern('user.name'); // 0 again — idempotent interner.lookup(0); // 'user.name' interner.size; // 2 ``` Ids are only comparable inside one interner's namespace, so BlaC keeps **one interner per container subclass**, keyed by constructor in a `WeakMap` (`StructuralContainer.getInternerFor`). Every instance of one `Cubit` subclass shares an interner, so their ids line up and can be unioned/intersected freely; distinct subclasses get distinct interners even if their state shapes match. The `WeakMap` lets the interner be collected once the class is gone. A `PathSet` is the unit of both "interest" and "dirtiness." It is either a concrete `Set` or the `ALL_PATHS` sentinel (`"every possible path"`). The set algebra is a `Space` — `PathSetSpace` — the same `Space` contract from Stage 1, now over path sets instead of booleans: ```ts import { PathInterner, PathSetSpace, ALL_PATHS, type PathSet, } from '@dirtytalk/structural'; const interner = new PathInterner(); const name = interner.intern('user.name'); // 0 const email = interner.intern('user.email'); // 1 // A listener that reads user.name; a mutation that changed user.email. const interest: PathSet = new Set([name]); const dirty: PathSet = new Set([email]); PathSetSpace.intersects(interest, dirty); // false → stays asleep PathSetSpace.intersects(new Set([name]), new Set([name])); // true → wakes // ALL_PATHS intersects any non-empty set (used for "wake everyone" signalling). PathSetSpace.intersects(ALL_PATHS, dirty); // true PathSetSpace.intersects(interest, ALL_PATHS); // true // ...but an empty interest never wakes — "I care about nothing". PathSetSpace.intersects(PathSetSpace.empty(), ALL_PATHS); // false ``` `ALL_PATHS` and the `PathSet` type are the only path primitives BlaC re-exports from `@blac/core` (for plugins composing channel subscriptions); this type-checked block uses them directly: ```ts twoslash import { ALL_PATHS, type PathSet } from '@blac/core'; // A plugin or devtools panel can declare blanket interest in every change. const blanketInterest: () => PathSet = () => ALL_PATHS; void blanketInterest; ``` The channel is identical to Stage 1; only the `Region` changed from `boolean` to `PathSet`. `intersects` does the discrimination: iterate the smaller set, look up in the larger, return on the first hit. That is the entire per-listener cost of a change — no tree walk. Where do the changed paths come from? The mutators compute them: - **`emit(next)`** installs a new immutable state, then diffs to find which observed paths changed value (Stage 3 covers the skeleton bound on that diff). - **`patch(partial)`** deep-merges a `DeepPartial` and marks paths via `changedPathsFromPatch`: it walks the patch shape, pulses each touched branch up (`{ user: { email } }` marks both `user` and `user.email`), but **value-filters** — a path is marked only if its value actually changed, and recursion prunes into unchanged branches (an unchanged subtree keeps its reference). So an over-spread patch that re-sets a whole parent when one field changed does not over-wake the siblings. - **`update(fn)`** is sugar for `emit(fn(state))`. From the public surface, the three mutators look like this — and all type-check: ```ts twoslash import { Cubit } from '@blac/core'; interface UserState { user: { name: string; email: string }; items: string[]; } class UserCubit extends Cubit { constructor() { super({ user: { name: 'Ada', email: 'ada@x.io' }, items: [] }); } // patch: deep-merges; marks only paths whose value actually changed. // Here only `user.email` moves → `user.name` consumers stay asleep. setEmail(email: string) { this.patch({ user: { email } }); } // update: sugar for emit(fn(state)); replace immutably. addItem(item: string) { this.update((s) => ({ ...s, items: [...s.items, item] })); } // emit: replace the whole state. reset() { this.emit({ user: { name: '', email: '' }, items: [] }); } } void UserCubit; ``` ``` Stage 2: paths as interned ids state = { user: { name, email }, items } intern: user.name → 0 user.email → 1 items → 2 emit changes user.email ──► dirty = { 1 } │ ┌────────────────┼────────────────┐ ▼ ▼ ▼ interest {0} interest {1} interest {2} (reads name) (reads email) (reads items) ∩ {1} = ∅ ∩ {1} = {1} ∩ {1} = ∅ asleep WAKE asleep ``` :::caution[In-place mutation is invisible] Change detection rests on reference comparison (`Object.is`) and on unchanged subtrees keeping their references. Mutating state in place — `state.items.push(x)` — marks nothing and wakes nobody. Always replace immutably: `patch({ ... })` or `update(s => ({ ...s, items: [...s.items, x] }))`. ::: What's still missing: a listener must somehow _declare_ its path set. Writing it by hand is a selector — the exact thing BlaC avoids. Stage 3 makes the read itself the declaration. --- ## Stage 3 — The recording proxy and the observed skeleton A consumer declares which paths it reads by _reading them_. `trackRender(state, interner)` wraps state in a recording `Proxy` and returns `{ value, paths }`: `value` is the proxy you read during render, `paths` is a **live** `Set` that grows as you touch properties off `value`. `trackRender` is internal (`@dirtytalk/structural`); the React adapter calls it for you on every render, so you never import it directly. Shown here to make the mechanism concrete: ```ts import { PathInterner, trackRender, type PathId } from '@dirtytalk/structural'; interface State { user: { name: string; email: string }; items: number[]; } const state: State = { user: { name: 'Ada', email: 'ada@x.io' }, items: [1, 2, 3], }; const interner = new PathInterner(); const { value, paths } = trackRender(state, interner); // Simulate what a component's render does: read the fields it displays. void value.user.name; // records `user.name` void value.items; // records `items` // `paths` now holds exactly the interned ids of what was read. const read: PathId[] = [...(paths as Set)].sort(); read.map((id) => interner.lookup(id)); // ['items', 'user.name'] ``` The recording rules are deliberate, and each one earns its keep: - **Leaf-only (maximal) recording.** Reading `user.name` records `user.name` and _drops_ the intermediate `user` from the set. So when an immutable update replaces the whole `user` object because a _sibling_ (`user.email`) changed, a consumer that only read `user.name` does **not** wake — its recorded leaf still resolves to the same value. Read the whole `user` object (no deeper key) and `user` stays your leaf, so any change inside it wakes you. - **Own, non-symbol reads only.** Symbol keys (`Symbol.iterator`) and inherited prototype props never record. Re-reads are idempotent (it's a `Set`). - **Nested plain objects/arrays return child proxies** recording into the same set, cached per target so `value.user === value.user` within one render. - **Iteration is per item.** `.map` / `.find` / `for..of` run against the proxy, so they record `items.length` and each field the callback reads (`items.0.title`, …). `includes` / `indexOf` / `lastIndexOf` run on the raw array and record `items`. - **Leaf collection types are not recursed.** `Map`, `Set`, `Date`, and class instances are returned raw (wrapping them would rebind receiver-checked built-ins like `Map.prototype.get`). A reference change to the whole value still wakes you, but an in-place `.set()` does not. Now the payoff. With `N` consumers sharing a container, the naive way to find who to wake is per-consumer diffing: `N` tree walks. BlaC does **one walk plus one lookup per changed path** instead. The trick is the **observed skeleton**: the union of every registered consumer's path set. On a mutation, `emit` diffs `prev` vs `next` **only along the skeleton** — it reads each skeleton path in both states and includes it iff the value moved (`diffAlongSkeleton`, default `Object.is`). The container keeps the skeleton as a path trie, so the walk stops at every unchanged reference: replacing one item of a 1,000-item list compares 1,000 item references and descends into one. That one walk produces the dirty set; then each changed path is looked up in an index of the consumers watching it (Stage 2). Again the helpers are internal — this is the mechanism `emit` runs for you: ```ts import { PathInterner, diffAlongSkeleton, pathSetUnion, type PathSet, } from '@dirtytalk/structural'; interface State { user: { name: string; email: string }; } const interner = new PathInterner(); // Two consumers: one reads user.name, the other reads user.email. const consumerA: PathSet = new Set([interner.intern('user.name')]); const consumerB: PathSet = new Set([interner.intern('user.email')]); // The skeleton is the union of all registered consumers' interests. const skeleton = pathSetUnion(consumerA, consumerB); const prev: State = { user: { name: 'Ada', email: 'ada@x.io' } }; const next: State = { user: { name: 'Ada', email: 'ada@new.io' } }; // ONE diff walk, bounded to the skeleton, finds what actually moved. const dirty = diffAlongSkeleton(prev, next, skeleton, interner); // dirty = { user.email } — only consumerB will wake on intersection. [...(dirty as Set)].map((id) => interner.lookup(id)); // ['user.email'] ``` The registry maintaining the skeleton is in `StructuralContainer`: `registerConsumerPaths(id, paths)` stores one consumer's set and recomputes the skeleton as the union of all of them (with a fast-path skip when the set is unchanged); `unregisterConsumer(id)` drops one and recomputes. ``` Stage 3: one walk, then N cheap intersections consumerA reads {user.name} consumerB reads {user.email} \ / \ / ▼ union ▼ skeleton = {user.name, user.email} │ emit(next) ─► diffAlongSkeleton(prev, next, skeleton) ◄── the ONE walk │ ▼ dirty = {user.email} ┌────────────┴────────────┐ ▼ ▼ A ∩ dirty = ∅ B ∩ dirty = {user.email} asleep WAKE ``` :::note[The zero-consumer skip] With no registered consumers, `emit`/`update`/`patch` skip the diff and mark `ALL_PATHS`, since only `ALL_PATHS` subscribers (plugins, `watch`) can be listening. From one consumer on, every change is diffed. ::: ### How React ties in `useBloc` needs **no selector** because the JSX _is_ the interest declaration. Per render it renders against the recording proxy, then registers the resulting path set in a layout effect — after render, since during render the set is still empty and would freeze an empty skeleton, silently dropping wakeups. The channel subscription reads the live interest on every flush, so it re-renders only on intersection. Conditional reads therefore reshape the skeleton automatically, render to render. To opt out and gate re-renders by a derived value array instead, pass `select` to `useBloc`. --- ## Stage 4 — Cross-bloc dependencies The final layer: one bloc leaning on another. This lives in `@blac/core`'s `StateContainer` (the class `Cubit` extends), not in the structural engine — deps are about _identity and lifecycle_, not path diffing. `this.depend(OtherBloc)` declares a dependency and returns a **DepHandle**. Calling `.untracked()` or `.track()` on the handle resolves the dependency against the registry, so the declaring bloc never holds a stale instance reference across dep churn: ```ts twoslash import { Cubit } from '@blac/core'; class AuthCubit extends Cubit<{ userId: string | null }> { constructor() { super({ userId: null }); } login(userId: string) { this.emit({ userId }); } } class DashboardCubit extends Cubit<{ ready: boolean }> { // `depend` returns a handle resolved against the registry per call. private auth = this.depend(AuthCubit); constructor() { super({ ready: false }); } get currentUser(): string | null { return this.auth.untracked().state.userId; // resolve, then read } } ``` Two design points fall out of the source: - **`depend` does not auto-resubscribe.** It records the dependency (visible via the `dependencies` getter) and returns a handle. It deliberately does **not** bridge the dep's channel into this bloc — a naive auto-bridge would cycle on mutual deps. A consumer that needs reactive updates from a dep subscribes explicitly, which in React means a component `useBloc`s both blocs and the Stage-3 tracker handles each independently. - **Per-consumer deps are a separate, owner-keyed mechanism.** The `APPLY_DEPS` / `REMOVE_DEPS_OWNER` symbols (from `@blac/core/internal`, used by the React adapter) let each _consumer_ inject a slice of deps keyed by an owner id; `StateContainer` shallow-merges all live owners' slices, dev-warns on cross-owner key collisions (last write wins), and fires `onDepsChanged` only when the merged view actually changes. This is distinct from `depend`'s bloc-to-bloc resolution. ``` Stage 4: deps resolve through the registry (no auto-bridge) DashboardCubit registry │ │ │ this.depend(AuthCubit) │ │ ── records dep, returns handle ──► │ │ │ │ auth.untracked() ─ ensure(AuthCubit)►│ ── returns the live │ │ AuthCubit instance │ ◄──────────── instance ──────────── │ ▼ reads dep.state (reactivity, if wanted, is the consumer's job via Stages 1–3 on each bloc independently) ``` `StateContainer` adds the rest of the lifecycle around these four stages: identity (`$blac.name`, `$blac.id`), disposal, hydration, an `onSystemEvent('stateChanged' | 'dispose' | 'hydrationChanged', cb)` surface, and a dev-only emit-rate circuit breaker that warns once when a runaway loop pushes too many changes through state in a second. But the reactivity itself is the four stages above: a coalescing channel, interned paths, a recording proxy feeding an observed skeleton, and a registry resolving cross-bloc deps. --- ## Putting it together: one update, end to end A single `emit` flows through all four stages: 1. A method calls `emit` / `update` / `patch`. `StateContainer` runs its guards (disposed, equality short-circuit, emit-rate check), captures the `prev`/`next` change, and delegates change-detection to `StructuralContainer`. (Stage 1 + 4 wrapper.) 2. The container installs the new immutable state and computes the dirty `PathSet` — `diffAlongSkeleton` against the observed skeleton for `emit`, `changedPathsFromPatch` for `patch` — then `channel.mark(dirty)`. (Stage 2 + 3.) 3. The `DirtyChannel` coalesces marks and schedules one flush per tick. (Stage 1.) 4. On flush, `useBloc` consumers are found by looking up each dirty path in the container's path index (`subscribePaths`); every other subscriber's interest thunk is evaluated and intersected with the dirty set. Only consumers with a non-empty intersection have their callback fired (React re-render, `watch` callback, plugin sink). (Stage 1 + 2.) 5. On the next render, the proxy re-records each consumer's interest from scratch, so conditional reads reshape the skeleton automatically. (Stage 3.) The practical takeaway is the same sentence the whole machine exists to make true, cheaply: **read exactly the state you render, keep state immutable, and BlaC re-renders the minimum.** ## See also - [Mental Model](/guide/mental-model) — the design narrative behind these stages. - [Tracking](/core/tracked) — the React-time recording rules in reference form. - [Dependency Tracking](/react/dependency-tracking) — `select` and what does/doesn't register. - [DirtyTalk](/dirtytalk/) — the standalone, engine-backed distillation. - [System Events](/core/system-events) — `stateChanged` / `dispose` lifecycle hooks. --- # What is BlaC? > BlaC is a TypeScript state management library for React that separates business logic into class-based, auto-tracked state containers. Source: /guide/introduction/ BlaC (Business Logic Components) is a TypeScript state management library for React. It separates business logic into class-based state containers — [Cubits](/guide/concepts/#state-containers) — that are type-safe, testable, and automatically optimized for minimal re-renders. A first taste — the whole loop in one screen: ```tsx twoslash import React from 'react'; import { Cubit } from '@blac/core'; import { useBloc } from '@blac/react'; class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } increment = () => this.emit({ count: this.state.count + 1 }); } function Counter() { const [state, counter] = useBloc(CounterCubit); return ; } ``` No store setup, no provider, no reducer, no selector. The class holds the logic; the hook connects it; re-renders are tracked automatically. That snippet isn't a screenshot — here it is running, the real `@blac/react` hook driving a real `Cubit`. The badge counts how many times the component has actually rendered: ## Why BlaC? Most state management libraries force you to choose between simplicity and power. Simple hooks-based solutions scatter logic across components. Powerful libraries require boilerplate, providers, and context wrappers. BlaC takes a different approach: - **State logic lives in classes, not components.** Define your state shape and mutations in a `Cubit` class. Components just read state and call methods. - **No providers or context wrappers.** Import your class, call `useBloc(MyClass)`, and you're connected. The registry handles instance creation and sharing automatically. - **Re-renders are precise by default.** Auto-tracking proxies detect which state properties your component reads during render. Only changes to those properties trigger re-renders. - **Lifecycle is declarative.** Instances are shared by default. Use `args` (with a `static key`) for named per-component instances or `@blac({ keepAlive: true })` for persistent singletons. - **Built for TypeScript.** State types flow from your class definition through the hook return value with zero type annotations needed. Two components share one bloc — each reads a different field. Increment the count or edit the label: only the component reading the changed field re-renders. :::note[How does this compare to Redux / Zustand / Jotai / MobX?] The short version: BlaC keeps logic in classes (like flutter_bloc), shares instances through a ref-counted registry (no providers), and tracks re-renders with a render-time proxy (no selectors or `useMemo`). ::: ## Architecture BlaC has two layers: ```text ┌─────────────────────────────┐ │ React useBloc hook │ Framework-specific binding, │ BlocProvider │ path-scoped channel subscriptions ├─────────────────────────────┤ │ Core Cubit, │ State containers, registry, │ Registry, │ plugins, watch, path-based │ Plugins │ dirty tracking └─────────────────────────────┘ ``` **Core** (`@blac/core`) provides state containers, a global registry with ref counting, a plugin system, and utilities like `watch`. Proxy-based dependency tracking is built in — no separate adapter package is needed. **React** (`@blac/react`) provides the `useBloc` hook. It subscribes each component to the bloc's path-scoped channel and re-renders through React's normal update path when a tracked read path changes. The optional `BlocProvider` shown above scopes default `args` to a subtree — most apps never need it (see [useBloc](/react/use-bloc) for when it helps).
Under the hood: the DirtyTalk engine family The "what changed, who cares, when do we tell them" machinery — path-based dirty tracking, the render-time proxy, microtask-batched flushing — lives in a lower-level, framework-agnostic family of packages called [DirtyTalk](/dirtytalk/). BlaC's `StateContainer` extends `@dirtytalk/structural`'s container; you never need to touch it directly, but it's there if you want to understand the foundation.
## When to use BlaC BlaC works best when: - You have **complex state logic** that benefits from being in a class (validation, derived state, async operations) - Multiple components need to **share state** without prop drilling or context providers - You want **testable business logic** that can run without React - You value **TypeScript inference** and want the compiler to catch state errors :::tip[When you probably don't need BlaC] If a piece of state lives in exactly one component and never travels, `useState` is the right tool — reach for BlaC when state is _shared_, _complex_, or _worth testing without React_. BlaC adds value as state complexity grows, not at the first `useState`. ::: ## What's next? - [Quick Start](/guide/getting-started) — Install BlaC and build your first component - [Core Concepts](/guide/concepts) — A quick tour of the mental model - [Mental Model](/guide/mental-model) — The deep "why it works this way," plus honest comparisons - [useBloc Hook](/react/use-bloc) — Full hook reference ## See also - [Mental Model](/guide/mental-model) — how auto-tracking, the registry, and batching actually work - [Quick Start](/guide/getting-started) — go from install to working component --- # Mental Model > The deep reasoning behind BlaC — the reactivity model, the per-consumer tracker, ref-counted lifecycle, and the full mount-to-unmount lifecycle. Source: /guide/mental-model/ This is the page for understanding _why_ BlaC works the way it does. If you want a fast orientation — the names of things and a one-screen tour — read [Core Concepts](/guide/concepts) first; it is the quick tour. This page is the deep version: the reactivity model end to end, the design choices behind it, honest comparisons to other libraries, and the full data-flow lifecycle from mount to unmount. Most of the surprising-at-first behavior in BlaC falls out of a single idea: **a component declares what it depends on by reading it, and BlaC wakes that component only when one of those exact reads would now produce a different value.** Everything else — proxies, path sets, ref counting, the registry — exists to make that one sentence true, cheaply, without you writing selectors. ## The reactivity model ### The problem auto-tracking solves Connect a component to a shared store and the default behavior of most subscription models is: _the store changed, so re-render the subscriber._ That is correct but wasteful. A container with twenty fields will wake a component that reads one of them nineteen times for nothing. The usual fixes push work onto you. Selectors (`useSelector(s => s.user.name)`) make you name your dependency by hand and keep it in sync as the component evolves. Memoized derivations (`useMemo`, `reselect`) make you declare input arrays. `React.memo` makes you reason about prop identity. All of these work; all of them are a second copy of "what this component reads" that can drift from the JSX, which is the real source of truth. BlaC's answer: **the JSX is the dependency declaration.** You read `state.name` in render; that read _is_ the subscription. Nothing to keep in sync, because there is only one copy. ### Read → track → intersect-on-change → re-render The mechanism is a loop with four stops. Walk it concretely with a `UserCubit` whose state is `{ name, email, address: { city } }`. **1. Read.** The `state` you get back from `useBloc` is not the raw state object — it is a recording `Proxy`. Built each render by `trackRender(rawState, bloc.interner)`, it returns a proxy plus a live `Set` of the paths read through it. ```tsx function UserName() { const [state] = useBloc(UserCubit); return {state.name}; // reads the path "name" } ``` **2. Track.** Each property access on the proxy records the path it touched. The recording is **leaf-only**: reading `state.address.city` records exactly `address.city`, not `address`. Reading a deeper path drops its immediate parent from the set. This is what gives you sibling isolation — a component reading `address.city` is not woken when `address.zip` changes, even though both live under the same parent object that got replaced. After the render commits, BlaC registers the recorded set as this consumer's "interest" (in a `useLayoutEffect`, after the proxy has actually been read — never during render, when the set is still empty). **3. Intersect on change.** When something calls `emit`, `patch`, or `update`, BlaC computes a **dirty set** — the paths whose values actually changed — and asks one question per consumer: _does this consumer's recorded interest intersect the dirty set?_ That is a cheap set-membership check, not a tree walk. The expensive part (figuring out what changed) happens **once per mutation**, not once per consumer. **4. Re-render.** Only consumers whose interest intersects the dirty set re-render. `UserName` re-renders when `name` changes; it stays still when `email` or `address.city` change. On that re-render, step 1 runs again — so if a conditional branch now reads a different field, the tracked set reshapes itself automatically. ```text emit / patch / update │ ▼ ┌──────────────────┐ │ compute dirty set │ one diff per mutation │ { "name" } │ (not per consumer) └──────────────────┘ │ ┌────────┴─────────────────────────────┐ │ for each consumer: interest ∩ dirty? │ cheap set checks ├───────────────────────────────────────┤ │ UserName {name} ∩ → wake │ │ UserEmail {email} ∩ → skip │ │ CityLabel {address.city}∩ → skip │ └───────────────────────────────────────┘ ``` The demo below puts this loop into motion. `CountReader` tracks only `state.count`; `LabelReader` tracks only `state.label`. The render badge on each component increments only when its tracked field changes — the other stays still. ### Each useBloc call gets its own tracker There is **one proxy and one recorded path set per `useBloc` call site**, not one shared per bloc. Two components reading the same `UserCubit` get two independent trackers with two independent interest sets. That is the whole point: re-render isolation is per consumer, so `UserName` and `UserEmail` can subscribe to the same instance and wake on different changes. :::caution[Per-consumer tracking, not a shared proxy] The proxy and tracker are created per `useBloc` call and torn down with it. There is deliberately no global "proxy cache" keyed by bloc — a shared proxy would force a shared interest set and collapse the isolation. If you are reading the source: each consumer has its own `pathRef` and its own channel subscription whose interest is `() => pathRef.current`. ::: ### Why one diff, not N diffs When many components share a container, computing "what changed" separately for each one re-does the same equality checks N times — quadratic in consumer count exactly when you have the most consumers. BlaC inverts it: maintain one **skeleton** (the union of every live consumer's interest), diff the change once bounded to that skeleton, then settle each consumer with a set intersection. One walk plus N cheap intersections. This is not a BlaC-specific trick; it is the core algorithm of the [`@dirtytalk/structural`](/dirtytalk/) engine that BlaC's `StateContainer` extends. For the path-interning, skeleton, and proxy-recorder internals in full, see [How BlaC Works Internally](/guide/internals). The short version of the recording rules: - **Leaf-only:** `a.b.c` records `a.b.c`, not `a.b`. - **Iteration coarsens:** `.map`/`for..of`/`.find` over `state.items` records `items`, not per-index paths. Direct index access (`state.items[2].name`) records the leaf. - **Leaf collection types are not recursed:** `Map`, `Set`, `Date`, and class instances are leaves — a change is seen only as a reference change at that value's own path. ### Two modes, chosen by presence of `select` There is no `autoTrack` flag. A consumer is in one of two modes, decided purely by whether you pass a `select` callback: | Mode | How interest is set | Re-renders when | | ------------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------ | | **Auto-track** (default, `select` omitted) | Proxy records the paths you read in render | A read path's value changes | | **`select` provided** | You return an array; subscription interest is `ALL_PATHS` | The returned array differs per-index (via `Object.is`) | `select` is the deliberate opt-out: you trade automatic per-path tracking for an explicit array, useful when you want to depend on a computed value or narrow a noisy read. See [Dependency Tracking](/react/dependency-tracking) for the decision guide and [useBloc](/react/use-bloc) for the full option list. ## Why these design choices Each of BlaC's load-bearing decisions answers a specific failure mode of the alternatives. ### Classes for business logic A Cubit is a class because business logic wants **encapsulation and methods**. State shape, the mutations that change it, derived values (getters), and async flows live in one cohesive unit: ```ts class CartCubit extends Cubit<{ items: Item[] }> { constructor() { super({ items: [] }); } add = (item: Item) => this.patch({ items: [...this.state.items, item] }); get total() { return this.state.items.reduce((n, i) => n + i.price, 0); } } ``` The payoff is testability: a Cubit has no dependency on React, the DOM, or hooks. You construct it, call methods, and assert on `state` directly. Compare this to logic spread across reducers, action creators, thunks, and selectors — three files for one concern. The class keeps the concern in one place, and `instanceof Cubit` works because `Cubit` is a real class (it extends `StateContainer`; there is **no** `Bloc` class). ### No context providers — a registry instead The cost of React Context for shared state is structural: every shared value needs a provider somewhere above its consumers, providers nest, and a context change re-renders the whole subtree unless you split contexts by hand. BlaC removes the tree dependency entirely. A global **registry** maps each class (plus an optional instance key) to one instance: ```tsx // no anywhere — just import and use const [state, cart] = useBloc(CartCubit); ``` Two components calling `useBloc(CartCubit)` get the same instance because the registry hands back the same object, regardless of where they sit in the tree. Sharing is by _identity in a registry_, not by _position under a provider_. You can still scope an instance to a subtree when you want to — distinct `args` (or a `BlocProvider` supplying default `args` to descendants) keys a distinct instance — but that is opt-in, not the price of admission. ### Ref-counted lifecycle — automatic disposal If instances live in a global registry, who frees them? A naive global store leaks: instances accumulate and never die. BlaC solves this with **reference counting**. Every `useBloc` call increments a ref on mount and releases it on unmount. When the count hits zero, the instance is **automatically disposed** and dropped from the registry — and disposal cascades to `ensure`-created dependencies that now also have zero refs. This is the lifecycle most context-free stores make you manage by hand. You only override it deliberately: `@blac({ keepAlive: true })` opts an instance out of auto-disposal so it survives at refcount zero (an app-wide singleton like auth or theme). Full registry-verb table lives in [Instance Management](/core/instance-management). ### The args / deps separation Inputs to a bloc come in two flavors with opposite requirements, and conflating them causes subtle bugs: - **`args`** is _serializable identity data_ — the user id whose data this bloc holds. It is forwarded to `init(args)` and used to compute the instance key, so two `useBloc(UserCubit, { args: { id: 7 } })` calls share one instance and a different id gets a different instance. Because it keys identity, `args` **must be serializable** (a function in `args` throws — put it in deps). - **`deps`** is _non-serializable, per-consumer handles_ — callbacks, API clients, refs. These must **not** affect identity (a shared instance can't have one identity per callback), so they are merged per consumer rather than baked into the key. Keeping these lanes separate is what lets a single shared instance accept per-consumer wiring without an override race (two consumers fighting over one value). The `args` lane is a first-class `useBloc` option and the sole source of instance identity; the `deps` runtime mechanism on `StateContainer` is currently internal and not exposed as a `useBloc` option in the React surface. The motivation and full mechanics live in [Inputs: args and deps](/guide/inputs). ### Immutable emit Change detection in step 3 relies on reference comparison — a path is "dirty" when its value is no longer `Object.is`-equal to before, and an _unchanged_ subtree is recognized by keeping its reference. That contract only holds if state is replaced immutably. So every mutator (`emit`, `patch`, `update`) installs a **new** value; none mutates in place, and there is no primitive that does. :::caution[In-place mutation is invisible] `this.state.items.push(x)` wakes no one. The array reference did not change, so its path is not dirty. Always replace: `this.patch({ items: [...this.state.items, x] })` or `this.update(s => ({ ...s, items: [...s.items, x] }))`. This is the single most common cause of "my component isn't updating" — see [Troubleshooting](/guide/troubleshooting). ::: A related consequence: mutations are **coalesced per microtask**. Several `emit`/`patch` calls in the same tick produce one flush — `prev` snapshotted at the first change, `next` at flush time — so consumers and plugins see one consistent transition instead of intermediate frames. This is a batching-for-consistency choice, not just performance: nobody observes a half-applied multi-field update. ## Honest comparisons BlaC borrows liberally and differs deliberately. Its distinctive bet is **transparent reactivity (like MobX) over immutable snapshots (like Redux), with no provider and automatic lifecycle (unlike both)** — the granularity of an atom library without managing atoms, the testability of a class without the reducer boilerplate. And when your problem is genuinely a serializable event log, a tree-scoped config value, or a handful of `useState` hooks, the honest answer is that another tool fits better. The full side-by-side — the rubric table, the Zustand/Jotai/BlaC counter written three ways, the "reach for it instead when" column for Redux, MobX, Context, and atoms, plus the decision guide — lives on the [Comparison](/guide/introduction/) page, which is the canonical home for positioning. ## The full lifecycle: mount to unmount Putting it together, here is what one `useBloc(UserCubit, { args: { id: 7 } })` call does across its life. ```text MOUNT 1. resolve instance key static key(args)? → structural key from args → provider context args → "default" 2. acquire(UserCubit, { args, refId }) ├─ exists? → return it, refs[refId]++ (share) └─ new? → new UserCubit(); registry configures instance; init(args) runs once; refs = 1 (create) 3. first render: trackRender wraps state → proxy + empty path set 4. you read state.name in JSX → path "name" recorded 5. useLayoutEffect: registerConsumerPaths(consumerId, { "name" }) 6. useEffect: subscribe(() => pathRef.current) to the channel 7. onMount(bloc) fires LIVE · a method calls emit/patch/update · changes coalesce within the microtask → one flush · dirty set computed once → intersected with each consumer's interest · matching consumers re-render → trackRender runs again → interest reshapes if reads changed · stateChanged / plugin onStateChange fire per flush UNMOUNT 8. onUnmount(bloc) fires (bloc still alive here) 9. release(UserCubit, { args, refId }) → refs[refId]-- 10. refs.size === 0 && !keepAlive? ├─ yes → dispose(): 'dispose' event, channel torn down, │ registry entry removed, cascade-dispose zero-ref deps └─ no → instance stays (another consumer, or keepAlive) 11. consumer's tracker + subscription torn down ``` Two details worth internalizing: - **Acquire/release agree on the key.** The same resolved instance key is used for both, so the bloc you acquired is the bloc you release. Get the key wrong (e.g. a non-serializable value in `args` producing a new key every render) and you create-and-dispose a fresh instance per render — the classic "new instance every render" symptom. - **`onUnmount` runs before `release`.** The bloc is still alive inside `onUnmount`, so you can read its final state there; only after does the ref drop and disposal possibly fire. ## See also - [Core Concepts](/guide/concepts) — the quick tour this page goes deep on - [Dependency Tracking](/react/dependency-tracking) — auto-track vs `select` in practice - [Inputs: args and deps](/guide/inputs) — the input lanes and identity precedence - [DirtyTalk](/dirtytalk/) — the path-tracking engine BlaC is built on --- # Troubleshooting & FAQ > A symptom-to-fix lookup table for the most common BlaC problems, with links into the reference pages that own each explanation. Source: /guide/troubleshooting/ This page is a fast lookup table for the problems that actually bite. Each entry is **symptom → cause → fix**, with a link into the reference page that owns the full explanation. If you are new and want the _why_ behind these behaviours, read the [Mental Model](/guide/mental-model) first — this page assumes you just want the answer. :::tip[How to use this page] Scan the **Symptom** column for the thing you are seeing, expand the matching detail block, apply the fix, and follow the link if you need depth. Nothing here invents new API — every fix uses the surface documented in [`useBloc`](/react/use-bloc), [Instance management](/core/instance-management), and [Configuration](/core/configuration). ::: ## Common problems ### Re-rendering | Symptom | Likely cause | Fix | | --------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | | Component never re-renders when state changes | State was read **outside** the render body | Read the value during render, not in an effect/callback | | Component never re-renders | State was **destructured/copied before** the tracked read | Read leaf properties off the live `state` proxy during render | | Component never re-renders | A `select` selector is present and never returns the changed value | Add the value to the selector array (or remove `select` to auto-track) | | Component never re-renders | State was **mutated in place** instead of replaced | Use `emit` / `update` / `patch` so a new reference is published | | Component re-renders too often | Reading a coarse path (whole array/object) instead of the leaf | Read the specific leaf you render; see [dependency tracking](/react/dependency-tracking) |
"My component does not re-render" — full walk-through Auto-tracking records a state path **only when you read it on the `state` proxy during render**. Four things break that contract: **1. The read happened outside render.** Reads inside `useEffect`, event handlers, `setTimeout`, or `onMount` are not recorded — only synchronous reads in the render body are. ```tsx // ✗ read happens in an effect — nothing is tracked, no re-render const [state, bloc] = useBloc(CounterCubit); useEffect(() => { console.log(state.count); // recorded? no. }, []); // ✓ read during render — `count` is tracked const [state] = useBloc(CounterCubit); return {state.count}; ``` **2. You destructured or copied before reading.** Pulling values into a local at the top, or spreading the state, can read a parent path (coarse) or no path at all, depending on how you then use it. Read the exact leaf you render, off the live proxy. ```tsx // △ spreading reads the parent, widening (or losing) what is tracked const { ...snapshot } = state; return {snapshot.count}; // ✓ read the leaf directly return {state.count}; ``` **3. You passed a `select` selector that omits the value.** Providing `select` opts **out** of auto-tracking entirely — the hook re-renders only when the returned array changes per index (`Object.is`). If the value you render is not in that array, it will never trigger a render. ```tsx // ✗ render uses `total`, but the selector only watches `count` const [state] = useBloc(CartCubit, { select: (s) => [s.count] }); return {state.total}; // ✓ either include it… const [state] = useBloc(CartCubit, { select: (s) => [s.count, s.total] }); // ✓ …or drop `select` and let auto-tracking handle it const [state] = useBloc(CartCubit); ``` **4. You mutated state in place.** `emit` short-circuits when `prev === next` by reference, and `patch` skips per-key when `Object.is(old, new)` holds. Mutating the existing object and re-emitting it publishes nothing. ```ts // ✗ same reference — emit no-ops update((s) => { s.count++; return s; }); // ✓ new reference update((s) => ({ ...s, count: s.count + 1 })); // ✓ or target the field patch({ count: this.state.count + 1 }); ``` Owner page: [Dependency tracking](/react/dependency-tracking). Mechanism: [Mental Model](/guide/mental-model).
:::caution[Map / Set / Date / class instances are leaves] The tracker wraps **plain objects and arrays** only. A `Map`, `Set`, `Date`, or class instance in state is treated as a single leaf value — a change is detected as a **reference change at that path**, never as a change to something inside it. Replace the whole value (`emit(new Map(prev).set(k, v))`) rather than mutating it in place. See [dependency tracking limitations](/react/dependency-tracking). ::: --- ### Stale values in callbacks | Symptom | Likely cause | Fix | | ------------------------------------ | -------------------------------------------------------------- | --------------------------------------------------------------------------- | | A callback fires with an old value | Closure captured a render-time snapshot | Read off the bloc inside the callback, or invert the callback into the bloc | | `deps` callback sees stale variables | Inline arrow re-created each render but captured stale closure | Make the handle stable; put the _logic_ in the bloc |
"Stale value in a callback" — callback inversion A callback you define in the component closes over the variables that existed **when that render ran**. If it fires later, it still sees those old values. ```tsx // ✗ `count` is frozen at the render that created this handler const [state, bloc] = useBloc(CounterCubit); const onClick = () => console.log(state.count); // stale ``` Two fixes, in order of preference: **Read off the bloc, not the closure.** The `bloc` instance is stable for the consumer's lifetime, and `bloc.state` is always current. ```tsx const [, bloc] = useBloc(CounterCubit); const onClick = () => console.log(bloc.state.count); // always current ``` **Invert the callback into the bloc.** Better still, move the behaviour onto the bloc as a method (an arrow-function class field, so `this` binds). The component just calls the action; there is no closure to go stale. ```ts class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } logCount = () => console.log(this.state.count); // `this.state` is live } ``` ```tsx const [, bloc] = useBloc(CounterCubit); return ; ``` If you must pass a callback **into** a bloc, route it through the `deps` lane (a non-serializable, per-consumer handle) rather than `args` — and keep the handle referentially stable so it does not re-key. Putting a fresh inline arrow in `deps` every render is the classic stale-closure trap. See [Passing inputs](/guide/inputs#deps-non-serializable-handles).
--- ### Instance identity (too many / too few) | Symptom | Likely cause | Fix | | --------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | Two instances when you expected one | `args` differ structurally between consumers (or a fresh object that hashes differently) | Ensure args are equal; narrow identity with `static key` | | A **new** instance every render | A function or non-serializable value is in `args` | Move it to `deps`; `args` must be JSON-serializable | | Everyone shares one instance, but you wanted one per item | All consumers use the same key (default, or same `args`) | Pass distinct `args` (e.g. a name field, or `_id: useId()` + `static key` for per-mount) | | Per-item components leak into each other | Same `args` key reused across items | Give each item distinct `args` (e.g. the row id) |
"I got two instances when I expected one" Identity is resolved from, in precedence order: own **`args`** (via `static key(args)`, else the **structural hash of `args`**) → `` context args → the `'default'` sentinel. Two consumers land on the same instance only when their resolved key matches. Common causes of an unexpected split: - **Args differ structurally.** `{ userId: '1' }` and `{ userId: 1 }` hash differently (string vs number). Normalise the type. - **You only want a subset of args to key the instance.** Use `static key` so config that "rides along" does not fork instances: ```ts class DocumentCubit extends Cubit< DocState, { docId: string; readonly: boolean } > { static key = (args: DocumentCubit['args']) => args.docId; // `readonly` does not fork } ``` `args` is memoised internally on `JSON.stringify(args)`, so a fresh object literal each render is fine **as long as its contents are equal**. Owner page: [Passing inputs](/guide/inputs#args-construction-data-that-keys-the-instance).
"A new instance is created on every render" If `args` contains a **function** (or other non-serializable value), the structural-key hash throws in dev with: ```text [blac] args must be serializable; put refs/callbacks in `deps` ``` In production the hash will differ each render and you get a brand-new instance constantly. The fix is always the same: **`args` is for serializable identity data only.** Refs, callbacks, and live handles go in the [`deps`](/guide/inputs#deps-non-serializable-handles) lane. ```tsx // ✗ callback in args — throws in dev, new instance in prod useBloc(ListCubit, { args: { onSelect: () => {} } }); // ✓ identity data in args, handle in deps (deps never key identity) useBloc(ListCubit, { args: { listId } /* , deps: { onSelect } via your deps wiring */, }); ```
"Everyone shares one instance when I wanted per-item" By default, a bloc with `void` args resolves to the single `'default'` key — every consumer shares it. To get one instance per item, give each consumer distinct `args`. Identity always comes from `args`; differ by intent only in _what_ you put there: | Want | Use | | ------------------------------------------------------ | ----------------------------------------------------------------- | | One instance per **data identity** (e.g. per `userId`) | `args: { userId }` — same args share, different args split | | One instance per **caller-chosen key** | a name field in args, e.g. `args: { row: row.id }` + `static key` | | One **fresh** instance per component mount | `args: { _id: useId() }` + `static key` | ```tsx // per data identity useBloc(UserCardCubit, { args: { userId } }); // caller-chosen key — each row is its own instance (static key = a => a.row) useBloc(RowCubit, { args: { row: row.id } }); ``` ```tsx // fresh per mount — useId() is a stable-per-mount value (static key = a => a._id) const _id = useId(); useBloc(WidgetCubit, { args: { _id } }); ``` A per-mount instance keyed off `useId()` always resolves to its own key, never the args supplied by an ancestor [`BlocProvider`](/react/use-bloc), so each mount stays private. Owner page: [Instance management](/core/instance-management).
:::caution[`args: { _id: useId() }`, not `autoInstance` or `instanceId`] You may see `autoInstance` or an `instanceId` option in old notes or stale comments — neither exists in the shipping API. The per-mount mechanism is a synthetic `args` field plus a `static key` that selects it (use `useId()` for a stable-per-mount value). There is no `isolated`, `autoInstance`, or `instanceId` field or hook option. ::: --- ### Lifecycle (disposed too early / never disposed) | Symptom | Likely cause | Fix | | -------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------ | | Instance disposed while still in use | A `depend()`/`ensure` reference holds no ref count | Hold a real ref (a `useBloc` consumer), or mark the source `keepAlive` | | Instance never disposed | A ref was acquired without a matching release | Pair every `acquire` with a `release` (outside React) | | State unexpectedly resets | Last consumer unmounted → auto-dispose → fresh instance on remount | Mark the class `@blac({ keepAlive: true })` if it should outlive its consumers | | State persists when you wanted a clean slate | `keepAlive` is on, so refcount 0 never disposes | Drop `keepAlive`, or `release(..., forceDispose)` / `clear` in teardown |
"Instance disposed too early / never disposed" — ref counting The registry ref-counts instances. Each `useBloc` consumer acquires one ref on mount and releases it on unmount. When the ref count reaches **0**, the instance is auto-disposed (unless it is `keepAlive`), and the dispose **cascades** to `ensure`-created dependencies that now also have zero refs. **Disposed too early.** `depend()` resolves its target via `ensure()`, which takes **no ref**. If nothing else holds the dependency, it can be disposed out from under you. Keep it alive by having a real consumer hold it, or mark the source class `keepAlive`. ```ts // inside a bloc — depend() does NOT keep the target alive on its own private user = this.depend(UserCubit); ``` ```ts // keep a shared, long-lived source alive @blac({ keepAlive: true }) class SessionCubit extends Cubit { /* … */ } ``` **Never disposed.** Outside React you manage refs yourself: every `acquire` must be paired with a `release`. A missing `release` leaks the instance forever. ```ts const refId = 'job-42'; const job = acquire(JobCubit, { refId }); // … later, always: release(JobCubit, { refId }); ``` `keepAlive` instances are never auto-disposed at refcount 0; tear them down explicitly with `release(C, { forceDispose: true })` or `clear(Class)` (a test/teardown utility — see [testing core](/testing/core)). Owner page: [Instance management](/core/instance-management); see also [Configuration](/core/configuration#-keepalive-true-).
--- ### Type errors | Symptom | Likely cause | Fix | | -------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------- | | `args` is **forbidden** (`never`) | The bloc's `Args` is the default `void` | Remove `args` from the `useBloc` call | | Wrong `args` shape causes a type error | Passed `args` don't match the bloc type | Match the bloc's declared `Args` shape; `args` is optional but typed when passed |
"'args' does not exist / wrong args shape" `useBloc`'s `args` option is **conditional on the bloc's type**: ```ts type ArgsOption = ExtractArgs extends void ? { args?: never } : { args?: ExtractArgs }; ``` - If the bloc extends `Cubit`, `args` is **optional** — omitting it inherits args from a `` ancestor, or falls back to the default key. When passed, it must match the declared `Args` shape. - If the bloc uses the default `void` args, `args` is typed `never` — **passing** it is a compile error. ```tsx // bloc: class UserCubit extends Cubit useBloc(UserCubit); // ✓ inherits BlocProvider args or uses default key useBloc(UserCubit, { args: { userId } }); // ✓ explicit args // bloc: class CounterCubit extends Cubit (void args) useBloc(CounterCubit, { args: {} }); // ✗ 'args' does not exist on type useBloc(CounterCubit); // ✓ ``` If you genuinely need to pass data to a `void`-args bloc, give the bloc an `Args` type. Owner page: [Passing inputs](/guide/inputs).
--- ### DevTools not showing | Symptom | Likely cause | Fix | | ---------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- | | DevTools panel empty / nothing appears | The DevTools plugin is not installed | `install` the DevTools plugin via `getPluginManager()` | | A specific bloc is missing from DevTools | The class is `excludeFromDevTools` | Remove `@blac({ excludeFromDevTools: true })` from that class | | DevTools loaded but no live updates | Plugin installed only for the wrong environment | Check the plugin's `environment` config matches `NODE_ENV` |
"DevTools not showing my blocs" Two independent gates control DevTools visibility: **1. The plugin must be installed.** DevTools is a plugin, not built in. Install it through the plugin manager, and respect its `environment` (a plugin configured for `'development'` is skipped in production by a runtime `NODE_ENV` check). ```ts import { getPluginManager } from '@blac/core/plugins'; // install your devtools plugin instance: getPluginManager().install(devtoolsPlugin, { environment: 'development' }); ``` **2. The class must not be excluded.** A class marked `excludeFromDevTools` (checked via `isExcludedFromDevTools`) is deliberately hidden: ```ts @blac({ excludeFromDevTools: true }) class NoiseCubit extends Cubit { /* … */ } ``` If you want this bloc visible, drop that option. The option is documented in [Configuration](/core/configuration); usage lives in [the DevTools plugin](/plugins/devtools).
--- ### SSR & hydration | Symptom | Likely cause | Fix | | ---------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------- | | Hydration mismatch warning from React | Server and client produced different first snapshots | Seed initial state from `args` in `init(args)` so both render the same | | Persisted state appears, then vanishes | State changed _while_ hydrating → discarded | Hold writes until `$blac.hydration.isHydrated`; observe `hydrationChanged` | | Instance key differs between server and client | Relying on internal auto-ids for stable identity | Key off stable `args` (or a `BlocProvider`) for SSR-stable identity |
"SSR / hydration notes" A few facts that matter when rendering on the server: - **First snapshot is synchronous.** `args` are forwarded to `init(args)` **once, before the first state snapshot**, so a bloc keyed by `args` renders identical initial state on server and client — no flash, no mismatch — provided the `args` are the same on both sides. - **`useBloc` does not fight React for SSR id slots.** Internally it uses a plain module counter for its consumer id (a `useId()` slot is reserved but unused), so it will not desync SSR-streamed ids. For identity that must be **stable across server/client**, derive it from stable `args` or a [`BlocProvider`](/react/use-bloc); do not rely on auto-generated ids. - **State hydration is a guarded lifecycle.** `$blac.hydration.begin()` → `$blac.hydration.apply(next)` → `$blac.hydration.finish()`. `apply` returns `false` (and skips) if the bloc was disposed, is not hydrating, or **`changedWhileHydrating`** — i.e. a normal write landed mid-hydration, so the live state wins and the persisted snapshot is discarded. If persisted state seems to be ignored, you almost certainly wrote to the bloc before hydration finished. Observe transitions via the [`hydrationChanged`](/core/system-events) system event and gate writes on `$blac.hydration.isHydrated`. See [Persistence](/plugins/persistence) for the full hydration story and [System events](/core/system-events) for `hydrationChanged`.
--- ## FAQ
Is there a `Bloc` class? No. The only base classes are `StateContainer` (abstract) and [`Cubit`](/core/cubit) (abstract, extends `StateContainer`). "Bloc" is colloquial shorthand for any container instance — see the [Core Concepts](/guide/concepts/).
How do I track a value without decorating it? Where is `@tracked`? There is no `@tracked` decorator and no `autoTrack` option. Tracking is **automatic and unconditional** whenever `select` is omitted — a render-time proxy records every leaf path you read. The only opt-out is supplying `select`. See [dependency tracking](/react/dependency-tracking).
`emit` vs `update` vs `patch`? `emit(next)` replaces the whole state; `update(fn)` is `emit(fn(state))`; `patch(partial)` deep-merges a `DeepPartial`. Note the equality check applies to `emit`/`update` only — `patch` filters per-key with its own `Object.is` comparison. A common mistake is calling `emit` with a partial object, which silently drops the missing fields. See [Cubit](/core/cubit).
Does `depend()` make my component re-render when the dependency changes? A plain `.untracked()` read does **not** auto-subscribe to the dependency's channel — but `.track()` does: read a dep's state via `const [s] = this.dep.track()` inside a getter and the component reading that getter re-renders when the dependency changes, no second `useBloc` needed. Calling a dependency's method (through `.untracked()`) never subscribes you to it. See [Bloc communication](/core/bloc-communication#auto-tracking-with-track).
`watch` callback fires async / `watch` never stops — what gives? `watch` fires once immediately, then on every change, but subscriptions are **microtask-deferred** — callbacks land after the synchronous `emit`. `watch` returns a cleanup function; if you never call it (or never return `watch.STOP`), the watcher leaks. Also note `watch` uses `ensure` and takes **no ref**, so it does not keep the bloc alive. See [`watch`](/core/watch).
My tests leak state between cases. The registry is global. Reset it between tests with the testing setup helpers so instances do not bleed across cases. See [Testing overview](/testing/overview) and [Testing core](/testing/core).
A circuit breaker threw ("max instances" / "max refs"). You hit `maxInstancesPerType` or `maxRefsPerInstance` from the global config — usually a leak (unreleased refs, or distinct `args` exploding instance count). Find the leak first; raise the limit (or set it to `Infinity` to disable) only if the count is legitimately high. `maxEmitsPerSecond` only warns in dev, never throws. See [Configuration](/core/configuration).
## See also - [Mental Model](/guide/mental-model) — the _why_ behind tracking, identity, and disposal - [Passing inputs](/guide/inputs) — `args` and `deps` in depth - [`useBloc`](/react/use-bloc) — the canonical options and precedence reference - [Instance management](/core/instance-management) — acquire/release, ref counting, `keepAlive` --- # Tutorial: build a Todo app, then make it time-travel > Build a Todo app one step at a time and finish with full undo, redo, and time travel using only BlaC's core state primitives. Source: /guide/tutorial/ This is the long way round — one app, built in seven numbered steps, each a small diff on the last. We start with an empty Cubit and finish with full **undo / redo and time travel**, all from the same state primitives you meet in the first step. No new APIs appear at the end; the payoff is that BlaC's plain-immutable-state model makes undo fall out almost for free. If you have not read [Quick Start](/guide/getting-started) and [Core Concepts](/guide/concepts) yet, skim them first — this page assumes you know what `emit`/`update`/`patch` do and that `useBloc` returns a `[state, bloc]` tuple. Everything else is built up here. There are two interactive checkpoints: one when the app first becomes usable, one at the end with time travel. The code blocks in between are type-checked against the real published API by the docs build, so they compile exactly as written. ## What we are building A todo list with: - adding, toggling, and removing items; - a filter (all / active / done) that is _view_ state, kept separate from the data; - a derived "items left" count; - and — the finale — a full history stack so every change can be undone, redone, or jumped to. The whole thing is **one Cubit**. Every component reads only the slice it needs, so adding a todo never re-renders the filter chips, and toggling the filter never re-renders the add box. That isolation is automatic; you will see it without writing a single selector for most of the app. ## Step 1 — the state shape and the first action Start with the data. A todo has an `id`, `text`, and a `done` flag. The Cubit's state is a list of them. Nothing else yet. ```ts twoslash import { Cubit } from '@blac/core'; export interface Todo { id: string; text: string; done: boolean; } export interface TodoState { todos: Todo[]; } export class TodoCubit extends Cubit { constructor() { super({ todos: [] }); } add = (text: string) => { const trimmed = text.trim(); if (!trimmed) return; // patch deep-merges: we mention only the key we change. this.patch({ todos: [ ...this.state.todos, { id: crypto.randomUUID(), text: trimmed, done: false }, ], }); }; } ``` Two things to notice, both from [Quick Start](/guide/getting-started): - `add` is an **arrow-function field**, not a method, so `this` stays bound when you pass `todo.add` straight to an event handler. - We build the next array with a spread (`[...this.state.todos, …]`) rather than `push`. State is treated as immutable; you always hand `patch`/`emit` a fresh value. That immutability is exactly what makes Step 7 possible. ## Step 2 — render it in React `useBloc(TodoCubit)` gives this component the shared instance and subscribes it to the state it reads. Here it reads `state.todos`, so it wakes whenever the list changes. ```tsx import { useBloc } from '@blac/react'; import { TodoCubit } from './TodoCubit'; function TodoList() { const [state] = useBloc(TodoCubit); return (
    {state.todos.map((t) => (
  • {t.text}
  • ))}
); } ``` And an input to drive `add`. This component reads _nothing_ from state — it only calls an action — so it never re-renders when the list changes: ```tsx import { useState } from 'react'; import { useBloc } from '@blac/react'; import { TodoCubit } from './TodoCubit'; function AddRow() { const [text, setText] = useState(''); const [, todo] = useBloc(TodoCubit); // only the bloc, no state read return ( setText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { todo.add(text); setText(''); } }} /> ); } ``` That `const [, todo] =` — dropping the state slot — is the idiom for an action-only consumer. There is no subscription to anything, so nothing wakes it. ## Step 3 — toggle and remove Two more actions on the Cubit. Both produce a brand-new array; neither mutates the old one. `toggle` maps over the list flipping one item's `done`; `remove` filters it out. ```ts twoslash import { Cubit } from '@blac/core'; interface Todo { id: string; text: string; done: boolean; } interface TodoState { todos: Todo[]; } class Base extends Cubit { constructor() { super({ todos: [] }); } } // ---cut--- class TodoCubit extends Base { toggle = (id: string) => { this.patch({ todos: this.state.todos.map((t) => t.id === id ? { ...t, done: !t.done } : t, ), }); }; remove = (id: string) => { this.patch({ todos: this.state.todos.filter((t) => t.id !== id) }); }; } ``` In the row component, wire a checkbox to `toggle` and a button to `remove`. A row only needs the bloc, plus the props passed by its parent: ```tsx function TodoRow({ id, text, done }: Todo) { const [, todo] = useBloc(TodoCubit); return (
  • todo.toggle(id)} /> {text}
  • ); } ``` ## Step 4 — a filter, kept separate from the data The filter is **view state**: which subset of the list to show. It is _not_ part of the todo data, so it gets its own key. Crucially, keeping it separate is what lets the history in Step 7 record only the data and ignore the view. Add a `filter` field and a derived `visible` getter that applies it. Getters recompute on every read, so `visible` can never drift out of sync with `todos` or `filter`. ```ts twoslash import { Cubit } from '@blac/core'; interface Todo { id: string; text: string; done: boolean; } type Filter = 'all' | 'active' | 'done'; interface TodoState { todos: Todo[]; filter: Filter; } export class TodoCubit extends Cubit { constructor() { super({ todos: [], filter: 'all' }); } setFilter = (filter: Filter) => this.patch({ filter }); // Derived on every read — never stored, never stale. get visible(): Todo[] { const { todos, filter } = this.state; if (filter === 'active') return todos.filter((t) => !t.done); if (filter === 'done') return todos.filter((t) => t.done); return todos; } get remaining(): number { return this.state.todos.filter((t) => !t.done).length; } } ``` Auto-tracking handles getters read during render: the `todo` value returned by `useBloc` is proxied, so `todo.visible` records the `todos` and `filter` reads inside the getter. Here we still use `select` because `visible` allocates a filtered array; the selector makes that computed array the explicit re-render boundary: ```tsx function List() { // Re-render whenever the computed `visible` list changes. const [, todo] = useBloc(TodoCubit, { select: (_, bloc) => [bloc.visible], }); return (
      {todo.visible.map((t) => ( ))}
    ); } ``` `select` returns an array; the component re-renders when any entry changes by identity. That identity boundary matters: this getter may allocate a new filtered array when it runs, so keep the selector focused on values that should wake the list. In this tutorial, `todos` and `filter` are the only fields that affect `visible`, so this wakes the list at the right moments. If unrelated state changes also cause the selector to run, memoize the derived array or select the source fields instead. ### Checkpoint — it works That is a complete, usable todo app: add, toggle, remove, filter, and a live "items left" count. Try it below — add a few items, toggle some, switch filters. Hit **View code** and edit anything; the preview re-runs live. Open `/TodoCubit.ts` in the sandbox — that one class is the entire app's logic. The components in `/App.tsx` are thin: each calls `useBloc`, reads its slice, and renders. No reducers, no actions object, no provider. ## Step 5 — seed it from somewhere (async) Real apps load their initial todos. An async action is just a method that `await`s and emits as it goes — BlaC has no special async primitive. We model the load lifecycle as state so the view can render "loading" and "error" instead of guessing. We will track the load status alongside the todos. A request-id guard makes a slow response unable to clobber a newer one — the full reasoning is in the [Async guide](/guide/async); here is the shape applied to our Cubit: ```ts twoslash import { Cubit } from '@blac/core'; interface Todo { id: string; text: string; done: boolean; } type Filter = 'all' | 'active' | 'done'; declare const api: { fetchTodos(): Promise }; // ---cut--- type LoadStatus = 'idle' | 'loading' | 'error'; interface TodoState { todos: Todo[]; filter: Filter; status: LoadStatus; } export class TodoCubit extends Cubit { private requestId = 0; constructor() { super({ todos: [], filter: 'all', status: 'idle' }); } load = async () => { const reqId = ++this.requestId; // claim the latest slot this.patch({ status: 'loading' }); try { const todos = await api.fetchTodos(); if (reqId !== this.requestId) return; // a newer call won; bail this.patch({ todos, status: 'idle' }); } catch { if (reqId !== this.requestId) return; this.patch({ status: 'error' }); } }; } ``` Kick the load off when the view appears using the `onMount` option — it fires after the bloc is acquired: ```tsx function App() { const [state, todo] = useBloc(TodoCubit, { onMount: (bloc) => bloc.load(), }); if (state.status === 'loading') return

    Loading…

    ; if (state.status === 'error') { return ; } return ; } ``` There is no Suspense boundary here, by design — the loading branch of this `if` _is_ the fallback, written by hand and fully type-checked. The [Async guide](/guide/async#suspense) explains why BlaC models loading as explicit state rather than throwing promises. :::tip[Loading-status state is also "view" state] Like `filter`, the transient `status` describes how to render, not the durable todo data. When we add history next, we will record only the todos — not `status`, not `filter` — so an undo never accidentally rewinds you into a stale "loading" screen. ::: ## Step 6 — route every mutation through one funnel Before we can add history, every change to the todo list must flow through a single place. Right now `add`, `toggle`, and `remove` each call `patch({ todos: … })` directly. Refactor them to compute the next list and hand it to one private `commit` helper. This is a pure refactor — behavior is identical — but it gives us the one chokepoint history will hook into. ```ts twoslash import { Cubit } from '@blac/core'; interface Todo { id: string; text: string; done: boolean; } type Filter = 'all' | 'active' | 'done'; interface TodoState { todos: Todo[]; filter: Filter; } // ---cut--- export class TodoCubit extends Cubit { constructor() { super({ todos: [], filter: 'all' }); } // The single funnel: takes the next todo list and commits it. private commit = (next: Todo[]) => this.patch({ todos: next }); add = (text: string) => { const trimmed = text.trim(); if (!trimmed) return; this.commit([ ...this.state.todos, { id: crypto.randomUUID(), text: trimmed, done: false }, ]); }; toggle = (id: string) => { this.commit( this.state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)), ); }; remove = (id: string) => { this.commit(this.state.todos.filter((t) => t.id !== id)); }; setFilter = (filter: Filter) => this.patch({ filter }); } ``` Note `setFilter` does **not** go through `commit` — switching filters should not create an undo entry. That separation we set up in Step 4 is paying off already. ## Step 7 — time travel Now the finale. Instead of storing a single `todos` array, store an array of _every_ list we have ever committed — a `past` stack — plus a `cursor` pointing at the one currently shown. `commit` appends; `undo`/`redo` just move the cursor; `jumpTo` sets it anywhere. Because BlaC state is plain immutable values, **a list of past values is already a complete undo history**. There is no diffing, no command pattern, no special engine — the snapshots are the values you were emitting all along. ```ts twoslash import { Cubit } from '@blac/core'; interface Todo { id: string; text: string; done: boolean; } type Filter = 'all' | 'active' | 'done'; // ---cut--- interface TodoState { // Every committed snapshot of the todo list, oldest first. past: Todo[][]; // Index into `past` of the snapshot we are showing. cursor: number; // Filter stays OUTSIDE history — switching it is not undoable. filter: Filter; } const EMPTY: Todo[] = []; export class TodoCubit extends Cubit { constructor() { super({ past: [EMPTY], cursor: 0, filter: 'all' }); } // Drop any "future" we had undone past, append the new snapshot, // and point the cursor at it. private commit = (next: Todo[]) => { const { past, cursor } = this.state; const kept = past.slice(0, cursor + 1); this.patch({ past: [...kept, next], cursor: kept.length }); }; // The list everything else reads: whatever the cursor points at. get todos(): Todo[] { return this.state.past[this.state.cursor]; } add = (text: string) => { const trimmed = text.trim(); if (!trimmed) return; this.commit([ ...this.todos, { id: crypto.randomUUID(), text: trimmed, done: false }, ]); }; toggle = (id: string) => { this.commit( this.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)), ); }; remove = (id: string) => { this.commit(this.todos.filter((t) => t.id !== id)); }; setFilter = (filter: Filter) => this.patch({ filter }); // Time travel: just move the cursor. No todo data is mutated. undo = () => { if (this.canUndo) this.patch({ cursor: this.state.cursor - 1 }); }; redo = () => { if (this.canRedo) this.patch({ cursor: this.state.cursor + 1 }); }; jumpTo = (index: number) => this.patch({ cursor: index }); get canUndo(): boolean { return this.state.cursor > 0; } get canRedo(): boolean { return this.state.cursor < this.state.past.length - 1; } get visible(): Todo[] { const { filter } = this.state; if (filter === 'active') return this.todos.filter((t) => !t.done); if (filter === 'done') return this.todos.filter((t) => t.done); return this.todos; } get remaining(): number { return this.todos.filter((t) => !t.done).length; } } ``` What changed, and what did not: - **`todos` became a getter** over `past[cursor]`. Every action and getter that used to read `this.state.todos` now reads `this.todos` instead — a one-word change at each call site. - **`add` / `toggle` / `remove` are untouched** apart from that. They still compute a next list and call `commit`. The funnel from Step 6 is the only thing that needed to learn about history. - **`undo` / `redo` / `jumpTo` mutate nothing but the cursor.** Going back in time is not a destructive operation; the future snapshots are still there until you commit a new change over them. - **`filter` is still outside the stack**, so switching filters or rewinding history are completely independent. The undo/redo buttons drive `undo`/`redo`; a row of dots renders one button per snapshot and calls `jumpTo`: ```tsx function HistoryBar() { const [, todo] = useBloc(TodoCubit, { select: (_, bloc) => [bloc.canUndo, bloc.canRedo], }); return (
    ); } function Timeline() { const [state, todo] = useBloc(TodoCubit, { select: (_, bloc) => [bloc.state.cursor, bloc.state.past.length], }); return (
    {state.past.map((_, i) => (
    ); } ``` ### Checkpoint — time travel Add several todos, toggle a few, then hit **Undo** repeatedly and watch the list rewind one change at a time. **Redo** walks forward again. Click any dot in the history row to jump straight to that point. Switch filters at any cursor position — the filter never disturbs the timeline. Open `/TodoCubit.ts` and compare it with the Step 4 version above. The diff that bought you a full undo system is small: a `past`/`cursor` pair instead of a bare `todos` array, a slightly smarter `commit`, three cursor-moving methods, and two boolean getters. Everything else — the actions, the components, the filter — barely moved. ## What you learned - **One Cubit, many thin consumers.** Each component reads only its slice via `useBloc`; action-only components read nothing and never re-render. - **`patch` for the data path; the replace-vs-merge rule from [Quick Start](/guide/getting-started).** State is always handed over as a fresh immutable value, never mutated in place. - **Derived getters over stored duplicates.** `visible`, `remaining`, `canUndo` recompute on read and cannot drift; auto-tracking records getter reads in render, and `select` can gate on getter results. - **View state lives apart from durable data.** `filter` and load `status` describe rendering, so they stay out of the history that records the todos. - **A single mutation funnel makes cross-cutting features cheap.** Once every change flowed through `commit`, undo/redo/time-travel was an afternoon, not a rewrite. - **Async is just methods that emit.** A request-id guard and an explicit status union replace Suspense — see the [Async guide](/guide/async). ## Where to go next - [Mental Model](/guide/mental-model) — _why_ the per-consumer tracking and immutable-state model work the way they do. - [Dependency Tracking](/react/dependency-tracking) — the full rules behind auto-tracking and `select`. - [Async](/guide/async) — the loadable surface, cancellation, and the Suspense rationale in depth. - [Bloc Communication](/core/bloc-communication) — composing blocs with `depend()`. - [Persistence](/plugins/persistence) — make your todos (or their history) survive a reload. --- # TypeScript > How state shape, action signatures, args, deps, and select all flow from a single Cubit class declaration with minimal annotations. Source: /guide/typescript/ BlaC is written in TypeScript and assumes you are too. Almost everything you need — state shape, action signatures, the args a consumer must pass — flows from a single class declaration, so most of this page is about _reading_ the inference rather than writing annotations. This is the discoverable, example-driven tour. For the exhaustive list of exported type utilities (`ExtractState`, `ExtractArgs`, `ExtractDeps`, `InstanceReadonlyState`, and friends), see [Core Types](/core/types). ## Compiler posture BlaC works with a standard strict React setup. The defaults that matter: ```jsonc { "compilerOptions": { "target": "ESNext", "jsx": "react-jsx", "strict": true, "useDefineForClassFields": true, "experimentalDecorators": true, // only if you use @blac(...) decorator syntax }, } ``` `strict: true` is the assumption behind every inference example below — `strictNullChecks` in particular is what makes `this.args` correctly `Args | undefined` and what forces you to narrow discriminated unions. Without it the examples still compile, but the safety they demonstrate is gone. Decorators are optional. The `@blac(...)` configuration decorator works as either a legacy (`experimentalDecorators`) decorator _or_ a TC39/stage-3 decorator — pick whichever your toolchain emits. If you'd rather not touch decorator flags at all, the functional form needs none: ```ts twoslash import { Cubit, blac } from '@blac/core'; // ---cut--- const AuthCubit = blac({ keepAlive: true })( class extends Cubit<{ user: string | null }> { constructor() { super({ user: null }); } }, ); ``` `blac(opts)(class)` is a plain higher-order function — no compiler flag, no syntax proposal. The decorator form is sugar over exactly this. See [Configuration](/core/configuration) for the full options union (`keepAlive`, `equality`, `excludeFromDevTools`, `key`). :::tip[Match getting-started] The tsconfig block above is the same one in [Getting Started](/guide/getting-started). If you change one, change both. ::: ## The three generics Both `Cubit` and `StateContainer` take the same three type parameters, in the same order — `State`, `Args`, `Deps`: ```ts abstract class StateContainer< S extends object = any, Args = void, Deps extends object = Record, > extends StructuralContainer {} abstract class Cubit< S extends object = any, Args = void, Deps extends object = Record, > extends StateContainer {} ``` So: | Param | Constraint | Default | What it is | | ------ | ---------------- | ----------------------- | ------------------------------------------------ | | `S` | `extends object` | `any` | The state shape. Always provide it. | | `Args` | none | `void` | Typed construction input passed to `init(args)`. | | `Deps` | `extends object` | `Record` | Non-serializable handles read via `this.deps`. | You only declare the ones you use, left to right. State-only is the common case: ```ts twoslash import { Cubit } from '@blac/core'; // ---cut--- interface CounterState { count: number; } class CounterCubit extends Cubit { constructor() { super({ count: 0 }); } increment = () => this.emit({ count: this.state.count + 1 }); } ``` ### How inference flows from `S` Declaring `S` is the only annotation you need — `state`, `emit`, `update`, and `patch` all specialize from it. Hover any of these and you'll see the concrete type, not `any`: ```ts twoslash import { Cubit } from '@blac/core'; // ---cut--- interface CounterState { count: number; label: string; } class CounterCubit extends Cubit { constructor() { super({ count: 0, label: 'start' }); } demo() { const c = this.state.count; // ^? this.emit({ count: 1, label: 'a' }); // emit/update take the FULL state this.update((s) => ({ ...s, count: s.count + 1 })); this.patch({ count: 2 }); // patch takes DeepPartial } } ``` `emit` and the value `update`'s callback returns both require the _complete_ `S` — omit a key and it's a type error, which is the compiler enforcing the replace-not-merge rule. `patch` accepts a `DeepPartial`, so partial objects are legal there and only there: ```ts twoslash // @errors: 2345 import { Cubit } from '@blac/core'; // ---cut--- interface CounterState { count: number; label: string; } class CounterCubit extends Cubit { constructor() { super({ count: 0, label: 'start' }); } bad() { this.emit({ count: 1 }); // missing `label` — emit replaces, so this is an error } } ``` ## Getters as derived state Anything computed from state belongs in a getter. Getters infer their return type, can't drift from the state they read, and require no extra type plumbing: ```ts twoslash import { Cubit } from '@blac/core'; // ---cut--- interface CartState { items: { price: number; qty: number }[]; } class CartCubit extends Cubit { constructor() { super({ items: [] }); } get total(): number { return this.state.items.reduce((sum, i) => sum + i.price * i.qty, 0); } get isEmpty() { // return type inferred as boolean — no annotation needed return this.state.items.length === 0; } } ``` :::tip[Getters track in render] Reading `cart.total` directly in render is reactive: the `bloc` returned by `useBloc` is proxied, and `this.state` inside the getter records through the current state proxy. Use [`select`](#typing-select) (`select: (state, bloc) => [bloc.total]`) only when the getter's return value should be the explicit re-render boundary. This is a _runtime_ tracking rule, not a type rule — the types let you read `cart.total` anywhere. See [Dependency Tracking](/react/dependency-tracking). ::: ## Discriminated unions and narrowing The single most useful TypeScript pattern in BlaC is a discriminated-union state. Model a request as a `status` tag and the compiler will _force_ you to handle every case and _forbid_ you from reading a field before it exists. Declare the union as the state type. Each variant carries only the data that's valid in that state: ```ts twoslash import { Cubit } from '@blac/core'; // ---cut--- interface User { id: string; name: string; } type UserState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; user: User } | { status: 'error'; error: string }; class UserCubit extends Cubit { constructor() { super({ status: 'idle' }); } load = async (id: string) => { this.emit({ status: 'loading' }); try { const user = await fetchUser(id); this.emit({ status: 'success', user }); } catch (e) { this.emit({ status: 'error', error: String(e) }); } }; } declare function fetchUser(id: string): Promise; ``` Because `emit` wants the full `UserState`, you can't emit `{ status: 'success' }` without a `user` — the variant's required fields are checked at the emit site: ```ts twoslash // @errors: 2345 import { Cubit } from '@blac/core'; // ---cut--- type UserState = | { status: 'idle' } | { status: 'success'; user: { id: string } }; class UserCubit extends Cubit { constructor() { super({ status: 'idle' }); } oops() { this.emit({ status: 'success' }); // forgot `user` } } ``` ### Narrowing on the read side The payoff is on the read side. Switch on `state.status` and inside each branch TypeScript narrows the union — `state.user` exists only in the `success` arm, `state.error` only in `error`. Here the consumer is a plain function so the inference is visible; in a component the same `state` comes out of `useBloc`: ```ts twoslash import { Cubit } from '@blac/core'; import { useBloc } from '@blac/react'; interface User { id: string; name: string; } type UserState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; user: User } | { status: 'error'; error: string }; declare class UserCubit extends Cubit { constructor(); } // ---cut--- function userLabel(): string { const [state] = useBloc(UserCubit); switch (state.status) { case 'idle': return 'Not loaded'; case 'loading': return 'Loading…'; case 'success': return `Hello ${state.user.name}`; // ^? (state narrowed to the success variant) case 'error': return `Failed: ${state.error}`; } } ``` Rendered in a component, that's an ordinary `switch` over `state.status`: ```tsx function UserCard() { const [state] = useBloc(UserCubit); switch (state.status) { case 'idle': return

    Not loaded

    ; case 'loading': return

    Loading…

    ; case 'success': return

    Hello {state.user.name}

    ; // state.user only exists here case 'error': return

    Failed: {state.error}

    ; } } ``` Reaching for a field outside its variant is a compile error, which is exactly the bug class discriminated unions exist to kill: ```ts twoslash // @errors: 2339 import { Cubit } from '@blac/core'; import { useBloc } from '@blac/react'; type UserState = | { status: 'loading' } | { status: 'success'; user: { name: string } }; declare class UserCubit extends Cubit { constructor(); } // ---cut--- function userName(): string { const [state] = useBloc(UserCubit); // no narrowing yet — `user` doesn't exist on the `loading` arm return state.user.name; } ``` :::tip[Exhaustiveness checking] Give your `switch` a `default` that assigns `state` to `never`. Add a new variant later and forget to handle it, and the assignment fails to compile — a free reminder. ```ts twoslash // @errors: 2322 type UserState = { status: 'idle' } | { status: 'loading' }; declare const state: UserState; // ---cut--- function render(): string { switch (state.status) { case 'idle': return 'idle'; // forgot the 'loading' case default: { const _exhaustive: never = state; // error: 'loading' is not assignable to never return _exhaustive; } } } ``` ::: Narrowing works identically inside a getter — derive a flag once and read it everywhere: ```ts twoslash import { Cubit } from '@blac/core'; // ---cut--- type UserState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; user: { name: string } } | { status: 'error'; error: string }; class UserCubit extends Cubit { constructor() { super({ status: 'idle' }); } get displayName(): string { const s = this.state; return s.status === 'success' ? s.user.name : 'Guest'; // ^? (narrowed to the success variant) } } ``` ## Typing `select` `select` lets a consumer use an explicit dependency array instead of auto-tracked reads. The component re-renders only when one of those values changes (compared per-index with `Object.is`). Its signature is: ```ts twoslash import type { ExtractState, InstanceReadonlyState, StateContainerConstructor, } from '@blac/core'; // ---cut--- type Select = ( state: ExtractState, bloc: InstanceReadonlyState, ) => unknown[]; ``` Both arguments are fully inferred from the bloc you pass to `useBloc` — `state` is the readonly state, `bloc` is the readonly instance (so getters are reachable). The return type is `unknown[]`, an array of whatever values gate the re-render: ```ts twoslash import { Cubit } from '@blac/core'; import { useBloc } from '@blac/react'; // ---cut--- interface CartState { items: { price: number; qty: number }[]; coupon: string | null; } class CartCubit extends Cubit { constructor() { super({ items: [], coupon: null }); } get total() { return this.state.items.reduce((s, i) => s + i.price * i.qty, 0); } } function cartTotal(): number { // re-renders only when `total` or item count changes const [, cart] = useBloc(CartCubit, { select: (state, bloc) => [bloc.total, state.items.length], // ^? }); return cart.total; } ``` `state` is `Readonly`, so `select` can't accidentally mutate it; that's also why reading a getter here (`bloc.total`) is the supported way to make a derived value drive re-renders. See [useBloc](/react/use-bloc#select). ## Conditional `args` When a bloc declares an `Args` type, `args` in `useBloc` is _optional_ — you may omit it and the hook will inherit args from a `` ancestor, or fall back to the default instance key. When the bloc's `Args` is the default `void`, passing `args` is _forbidden_. The type system enforces both directions through a conditional option type: ```ts twoslash import type { ExtractArgs, StateContainerConstructor } from '@blac/core'; // ---cut--- type ArgsOption = ExtractArgs extends void ? { args?: never } : { args?: ExtractArgs }; ``` `ExtractArgs` pulls the second generic off the class. If it's `void`, the option becomes `{ args?: never }` — present-but-forbidden. Otherwise it's `{ args?: Args }` — optional and typed. A void-args bloc rejects `args` (the option's type there is `never`): ```ts twoslash // @errors: 2322 import { Cubit } from '@blac/core'; import { useBloc } from '@blac/react'; // ---cut--- class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } } function count(): number { // CounterCubit has no Args → passing `args` is a type error const [state] = useBloc(CounterCubit, { args: { foo: 1 } }); return state.count; } ``` A bloc that declares `Args` accepts it as optional — omitting it inherits args from a `` ancestor, else the default key is used. When you do pass `args`, it is type-checked against the declared shape: ```ts twoslash import { Cubit } from '@blac/core'; import { useBloc } from '@blac/react'; // ---cut--- interface UserState { name: string; } class UserCubit extends Cubit { constructor() { super({ name: '' }); } protected init(args: { userId: string }) { void args.userId; } } function userName(): string { // args is optional — omit to inherit from BlocProvider, or pass explicitly: const [state] = useBloc(UserCubit, { args: { userId: '42' } }); return state.name; } ``` Provide it correctly and `args` is type-checked against the declared shape: ```ts twoslash import { Cubit } from '@blac/core'; import { useBloc } from '@blac/react'; interface UserState { name: string; } class UserCubit extends Cubit { constructor() { super({ name: '' }); } } // ---cut--- function userName(userId: string): string { const [state, user] = useBloc(UserCubit, { args: { userId } }); // ^? return state.name; } ``` Inside the bloc, the `args` getter is `Args | undefined` (it's unset until the instance is acquired), so reach for the value `init(args)` hands you when you need it non-optionally: ```ts twoslash import { Cubit } from '@blac/core'; // ---cut--- class UserCubit extends Cubit<{ name: string }, { userId: string }> { constructor() { super({ name: '' }); } protected init(args: { userId: string }) { // `args` here is the non-optional declared shape; init just seeds state } // Side effects belong in onActivate, not init. protected onActivate(signal: AbortSignal) { void this.fetch(this.args!.userId); } retry() { // the `args` GETTER is `Args | undefined` — guard it const id = this.args?.userId; // ^? if (id) void this.fetch(id); } private async fetch(_id: string) {} } ``` ## A typed custom hook Wrapping `useBloc` in a domain hook is the idiomatic way to give a feature a named, typed entry point. Inference is preserved end to end as long as you don't widen the return — let it flow: ```ts twoslash import { Cubit } from '@blac/core'; import { useBloc } from '@blac/react'; // ---cut--- interface TodoState { items: string[]; filter: 'all' | 'active' | 'done'; } class TodoCubit extends Cubit { constructor() { super({ items: [], filter: 'all' }); } add = (t: string) => this.patch({ items: [...this.state.items, t] }); get count() { return this.state.items.length; } } // Custom hook: no explicit return annotation needed — it's inferred as // [Readonly, InstanceReadonlyState, ...] function useTodos() { return useBloc(TodoCubit); } function todoCount(): number { const [state, todo] = useTodos(); // ^? todo.add('x'); return state.items.length; } ``` For a hook that takes arguments, type the parameters and forward them — the bloc's `Args` requirement still applies at the call site inside the hook: ```ts twoslash import { Cubit } from '@blac/core'; import { useBloc } from '@blac/react'; // ---cut--- interface UserState { name: string; } class UserCubit extends Cubit { constructor() { super({ name: '' }); } protected init(args: { userId: string }) { void args.userId; } } // the hook's signature documents what the feature needs function useUser(userId: string) { return useBloc(UserCubit, { args: { userId } }); } function profileName(id: string): string { const [state] = useUser(id); return state.name; } ``` If you _do_ want to annotate the return — for a public package API, say — derive it from the bloc rather than restating the shape. `ExtractState` and `InstanceReadonlyState` are exported from `@blac/core`: ```ts twoslash import { Cubit } from '@blac/core'; import type { ExtractState, InstanceReadonlyState } from '@blac/core'; import { useBloc } from '@blac/react'; declare class TodoCubit extends Cubit<{ items: string[] }> { constructor(); } // ---cut--- type TodoState = ExtractState; // ^? type TodoBloc = InstanceReadonlyState; function useTodos(): [TodoState, TodoBloc] { const [state, bloc] = useBloc(TodoCubit); return [state, bloc]; } ``` These utilities, plus `ExtractArgs`, `ExtractDeps`, and the rest, are documented in full in [Core Types](/core/types). ## See also - [Core Types](/core/types) — the exhaustive reference for every exported type utility - [Cubit](/core/cubit) — `emit` / `update` / `patch`, `init`, getters, and lifecycle - [useBloc](/react/use-bloc) — the hook, its option surface, and `select` - [Passing Inputs](/guide/inputs) — the `args` / `deps` / events model in depth - [Dependency Tracking](/react/dependency-tracking) — how getter reads participate in auto-tracking --- # Bloc communication > Use depend() to let one Cubit read another bloc's state or call its methods without holding a hard reference, with opt-in reactive cross-bloc tracking. Source: /core/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](/core/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](/core/instance-management), so each bloc stays decoupled from how the other is created or keyed. :::note[Mental model] Think of `depend()` as "I need to know about that bloc, but I don't own it." It records an intent ("CartCubit depends on ShippingCubit") and hands you a handle that fetches the live instance on demand — `.untracked()` for a plain read or method call, `.track()` to subscribe the reading component reactively. Ownership and lifetime still flow through the registry's [ref counting](/core/instance-management) — see [Lifecycle: who keeps the dependency alive?](#lifecycle-who-keeps-the-dependency-alive) below for the gotcha this creates. ::: ## `depend(Type)` 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. ```ts protected depend( Type: T, defaultArgs?: ExtractArgs, ): DepHandle ``` | Parameter | Type | Required | Description | | ------------- | ------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Type` | `T extends StateContainerConstructor` | yes | The state-container class to depend on. | | `defaultArgs` | `ExtractArgs` | no | The `args` that identify which [keyed instance](/core/instance-management) to resolve when an accessor is called without its own `args`. Defaults to the default instance. | **Returns:** a [`DepHandle`](#what-track-returns) — 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()`](#auto-tracking-with-track). Both accept an optional `{ args }` to resolve a specific [keyed instance](/core/instance-management) 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. :::tip[This is the part most state libraries can't do] A getter on one bloc can read **another bloc's** state (and its getters) and, via `.track()`, the React component reading that getter wakes when _either_ bloc changes — with no `useBloc(Other)`, no selectors, and no provider wiring. Jump to [Auto-tracking with `.track()`](#auto-tracking-with-track) for the whole story. ::: **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. :::caution[`depend()` does not auto-subscribe] `depend()` only records the dependency and resolves the instance — it does **not** wire up a reactive subscription between the two blocs. Inside the bloc, `this.shipping.untracked().state.rate` is a plain read; it returns the current value but does not cause `CartCubit` to re-emit when shipping changes. Reactivity is supplied by the consumer that reads the derived value: a React component via the [auto-tracking proxy](/react/dependency-tracking), or non-React code via [`watch()`](/core/watch). If you need a bloc itself to react to a dependency's changes, subscribe explicitly (e.g. `watch(...)`) and tear it down in [`dispose`](/core/system-events). This is deliberate — a naive auto-bridge would loop forever on mutual dependencies. ::: ```ts twoslash import { Cubit } from '@blac/core'; class ShippingCubit extends Cubit<{ rate: number }> { constructor() { super({ rate: 5.99 }); } } interface CartItem { price: number; } class CartCubit extends Cubit<{ items: CartItem[] }> { private shipping = this.depend(ShippingCubit); constructor() { super({ items: [] }); } get total() { const subtotal = this.state.items.reduce((sum, i) => sum + i.price, 0); // Plain (untracked) read — see `.track()` below for the reactive version. return subtotal + this.shipping.untracked().state.rate; } } ``` ### How `depend()` works 1. `this.depend(ShippingCubit)` records the dependency (`ShippingCubit` → instance key) and returns a `DepHandle`. 2. Calling `.untracked()` (or `.track()`) resolves the instance via [`ensure()`](/core/instance-management) 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()`](#auto-tracking-with-track) on the handle. ### Named instance dependencies By default, `depend(Type)` targets the `'default'` instance key. To depend on a specific [named instance](/core/instance-management), pass the `defaultArgs` that identify it (or override per call with `.track({ args })` / `.untracked({ args })`): ```ts twoslash import { Cubit } from '@blac/core'; class EditorCubit extends Cubit<{ content: string }, { docId: string }> { static key = (a: { docId: string }) => a.docId; constructor() { super({ content: '' }); } } class ReviewCubit extends Cubit<{ approved: boolean }> { private editor = this.depend(EditorCubit, { docId: 'doc-42' }); constructor() { super({ 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](/guide/inputs) for how keys are derived. ## Alternatives ### `ensure()` and `borrow()` For one-off access outside the class constructor, use registry functions directly: ```ts twoslash import { Cubit, borrow } from '@blac/core'; interface NotificationState { message: string; } class UserCubit extends Cubit<{ name: string }> { constructor() { super({ name: '' }); } } class NotificationCubit extends Cubit { constructor() { super({ message: '' }); } showUserError = () => { const user = borrow(UserCubit); // must already exist this.patch({ message: `Error for ${user.state.name}` }); }; } ``` ### When to use which | Approach | Use 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? `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`](/react/use-bloc)). 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](/core/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 Two cross-bloc patterns reliably cause bugs. Both are easy to avoid once you know the rule: **resolve dependencies lazily, read them late.** :::caution[Common mistakes] **Reading a dependency's state in the constructor.** The dependency may not be initialized yet — its `init()` may not have run, so its state is still the raw initial value. Read deps inside getters or methods (or `init`), never in the constructor. **Mutual `depend()` between two blocs.** `A` depends on `B` and `B` depends on `A`. Declaration alone is safe (resolution is lazy), but if you also wire explicit subscriptions both ways, a change in one re-emits the other forever. The channel's same-tick coalescing limits the blast radius, but a true mutual reactive cycle is a design smell — extract the shared concern into a third bloc that both depend on, with the dependency arrows pointing one way. ::: 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 [Core Concepts](/guide/concepts/) for when cross-bloc coupling is a smell versus a sound dependency. ## Calling methods on dependencies Dependencies aren't just for reading state — you can call methods on them to trigger side effects in other blocs. ```ts twoslash import { Cubit } from '@blac/core'; interface Message { userId: string; text: string; } interface ChannelState { channelId: string; messages: Message[]; } class NotificationCubit extends Cubit<{ unread: number }> { constructor() { super({ unread: 0 }); } incrementUnread = (_channelId: string) => { this.update((s) => ({ unread: s.unread + 1 })); }; clearUnread = (_channelId: string) => { this.emit({ unread: 0 }); }; } class ChannelCubit extends Cubit { private notifications = this.depend(NotificationCubit); constructor() { super({ channelId: '', messages: [] }); } receiveMessage = (message: Message) => { this.update((s) => ({ ...s, messages: [...s.messages, message], })); // Trigger a side effect in another bloc. `.untracked()` resolves the live // instance without subscribing — a method call needs no reactivity. this.notifications.untracked().incrementUnread(this.state.channelId); }; markAsRead = () => { this.notifications.untracked().clearUnread(this.state.channelId); }; } ``` This keeps notification logic in `NotificationCubit` while letting `ChannelCubit` coordinate when it fires. :::tip[Reading state vs calling methods] These behave differently with respect to re-renders. **Reading** a dependency's `state` via `.track()` inside a getter subscribes the consuming component to that path (via the [auto-tracking proxy](/react/dependency-tracking)). **Calling a method** on a dependency through `.untracked()` — like `incrementUnread()` above — does not subscribe to anything; it just triggers a side effect in the other bloc. A component that only triggers actions on a dependency will not re-render when that dependency's state changes, which is exactly what you want for action-only coordination. ::: ## Derived getters across blocs 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. ```ts twoslash import { Cubit } from '@blac/core'; class AuthCubit extends Cubit<{ user: { name: string } | null }> { constructor() { super({ user: null }); } } class CartCubit extends Cubit<{ items: string[] }> { constructor() { super({ items: [] }); } } class DashboardCubit extends Cubit> { private auth = this.depend(AuthCubit); private cart = this.depend(CartCubit); constructor() { super({}); } get summary() { const [authState] = this.auth.track(); const [cartState] = this.cart.track(); const user = authState.user; const itemCount = cartState.items.length; return `${user?.name ?? 'Guest'} has ${itemCount} items`; } } ``` :::caution[A coordinating bloc with empty state is a smell] `Cubit<{}>` here holds no state of its own — it exists purely to compose other blocs' state. That is fine for a small read-only aggregator, but if it grows methods and starts coordinating writes across many blocs, it tends to become a god-bloc that re-couples everything `depend()` was meant to decouple. Prefer reading derived values directly in the component (or a small focused bloc per view) over one central dashboard bloc. See [Core Concepts](/guide/concepts/). ::: ## Auto-tracking with `.track()` `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: ```ts 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: ```ts // In a React component: const [, cart] = useBloc(CartBloc); return {cart.total}; // re-renders when qty OR price.amount changes ``` ### What `.track()` returns ```ts handle.track(); // → [trackedState, depProxy] ``` | Element | Type | Description | | -------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `trackedState` | dep's state | 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](#dep-getter-transitivity)). | ### Render-aware: safe everywhere `.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: ```ts 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. ### Dep getter transitivity 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: ```ts 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. ### Conditional tracking `.track()` is called during the render of the getter. If it runs conditionally, the subscription is added or dropped automatically after each render: ```ts 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. ### Mutual dependencies and cycle safety 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. ### `select` mode stays primary-only 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. ## On-demand instance creation Sometimes a dependency doesn't exist yet and needs to be created conditionally. Use `borrowSafe` to check and `acquire` to create on demand: ```ts twoslash import { Cubit, borrowSafe, acquire } from '@blac/core'; class UserCubit extends Cubit<{ userId: string }, { userId: string }> { static key = (a: { userId: string }) => a.userId; constructor() { super({ userId: '' }); } protected init(args: { userId: string }) { this.patch({ userId: args.userId }); } setUserId = (id: string) => this.patch({ userId: id }); } interface ChannelState { messages: string[]; } interface MessageData { userId: string; text: string; } class ChannelCubit extends Cubit { constructor() { super({ messages: [] }); } private ensureUserCubit(userId: string) { const result = borrowSafe(UserCubit, { args: { userId } }); if (!result.error) return; // already exists // Create on demand, keyed by userId (init seeds the state) acquire(UserCubit, { args: { userId } }); } receiveMessage = (message: MessageData) => { this.ensureUserCubit(message.userId); this.update((s) => ({ messages: [...s.messages, message.text] })); }; } ``` :::tip Use `borrowSafe` over `borrow` when the instance may not exist yet. `borrow` throws, while `borrowSafe` returns `{ error, instance }` so you can handle the missing case gracefully. ::: ## See also - [Instance Management](/core/instance-management) — the registry, ref counting, and `ensure`/`borrow`/`acquire` - [Inputs and identity](/guide/inputs) — how `args` and `static key` resolve the instance a `depend()` targets --- # Configuration > Per-class config with the @blac decorator (keepAlive, equality, key, excludeFromDevTools) and global config with configureBlac, including the circuit breakers that catch leaks and emit storms. Source: /core/configuration/ There are two layers of configuration in BlaC, and it helps to keep them straight: - **Per-class config** with the `@blac` decorator (or `blac()` function) — opt one bloc into special behavior: keep it alive, hide it from DevTools, override its equality check, or control how its instances are keyed. - **Global config** with `configureBlac` — set app-wide defaults and tune the safety limits (circuit breakers) that catch leaks and runaway emits before they freeze the app. This page covers both. If you just want one bloc to survive route changes, you want `@blac({ keepAlive: true })` below. If you want to swap the default equality check or raise a circuit-breaker limit, jump to [`configureBlac`](#global-config-configureblac). ## Per-class config: `@blac` `BlacOptions` is a **union type** — you can specify exactly **one** option per `@blac` call. Each option sets a single static property on the class that the registry and React layer read at runtime. ```ts import { blac, Cubit } from '@blac/core'; @blac({ keepAlive: true }) class AuthCubit extends Cubit { constructor() { super({ user: null }); } } ``` :::caution[One option per call] Because `BlacOptions` is a union, this is a **type error** — you cannot combine options in a single call: ```ts // Type error: object literal may only specify one of these members @blac({ keepAlive: true, key: (args) => args.id }) class Bad extends Cubit {} ``` To apply more than one, stack decorators (or chain function calls). Each call sets its own static property and returns the class: ```ts @blac({ keepAlive: true }) @blac({ key: (args) => args.id }) class Good extends Cubit {} ``` ::: ### `{ keepAlive: true }` Prevents the instance from being auto-disposed when its ref count reaches zero. The instance persists for the lifetime of the app (it can still be torn down explicitly via `clear()` or `release(..., forceDispose)`). **Why:** By default, BlaC disposes an instance the moment the last consumer releases it — see [Instance Management](/core/instance-management). That is exactly what you want for screen-local state, but wrong for state that must outlive the components reading it. ```ts @blac({ keepAlive: true }) class AuthCubit extends Cubit { constructor() { super({ user: null }); } } ``` **When to use:** global state that should survive route changes and component churn — authentication, user preferences, app-wide settings, a feature-flag cache. `keepAlive` is inherited by subclasses. Pass `@blac({ keepAlive: false })` on a subclass to turn it off again. ### `{ excludeFromDevTools: true }` Hides every instance of the class from DevTools inspection. **Why:** the DevTools plugin subscribes to each tracked container. A container that emits at high frequency (an animation clock, a pointer-position store) would flood the DevTools panel and add overhead on every frame. Excluding it keeps the panel readable and avoids self-induced feedback when DevTools-related state changes. ```ts @blac({ excludeFromDevTools: true }) class InternalTimerCubit extends Cubit { constructor() { super({ tick: 0 }); } } ``` **When to use:** high-frequency or purely internal state containers that would clutter DevTools. See [DevTools](/plugins/devtools) for how the plugin consumes this flag. ### `{ equality: EqualityFn }` Overrides the global equality check for this one class. The function receives the previous and next state; **return `true` to treat them as equal and skip the emit**. **Why:** `emit`/`update` short-circuit when the new state is considered equal to the old, which prevents needless re-renders and downstream work. The global default ([`shallowEqualState`](#defaults)) compares top-level keys with `Object.is`. If a specific bloc holds deeply nested state where a shallow check is too eager (or not eager enough), give it its own comparator. ```ts import { blac, Cubit, type EqualityFn } from '@blac/core'; const deepEqual: EqualityFn = (prev, next) => JSON.stringify(prev) === JSON.stringify(next); @blac({ equality: deepEqual }) class FilterCubit extends Cubit { constructor() { super({ tags: [], range: { min: 0, max: 100 } }); } } ``` :::note[Equality applies to emit/update, not patch] The configured equality function gates `emit` and `update` only. `patch` does its own per-key `Object.is` filtering and ignores this function — see [Cubit](/core/cubit) for the difference between the mutation methods. ::: ### `{ key: (args) => string }` Overrides how the registry derives an instance key from `args`. The function receives the bloc's `args` and returns the key string: **distinct return values produce distinct instances; identical values share one instance.** **Why:** by default, a bloc with `args` is keyed by a structural hash of the entire `args` object. That is usually right, but sometimes only part of `args` is identity-bearing. A `static key` lets you key on just the identifying field so that non-identity fields (display options, a callback flag) don't accidentally fork a brand-new instance every time they change. ```ts // Two consumers passing { userId: 'u1', highlight: true/false } // should share ONE instance keyed only by userId. @blac({ key: (args: { userId: string }) => args.userId }) class UserCardCubit extends Cubit< UserState, { userId: string; highlight?: boolean } > { constructor() { super({ user: null }); } } ``` The same key function can be set directly as a class static (`static key = (args) => …`) if you prefer not to use a decorator. The registry reads either form. See [Passing Inputs](/guide/inputs) for the full identity model, and [Instance Management](/core/instance-management) for how keys drive sharing and disposal. ## Without decorators If you're not using TypeScript decorators, call `blac` as a regular function. The decorator and function forms are identical — the decorator is just sugar for calling the returned wrapper on your class. ```ts class AuthCubit extends Cubit { constructor() { super({ user: null }); } } blac({ keepAlive: true })(AuthCubit); ``` :::note[Decorator setup] The decorator form (`@blac({ … })`) requires decorator support in your toolchain — `experimentalDecorators` in `tsconfig.json` for the TypeScript-style decorators, or a build that understands TC39 decorators. If you hit a decorator-syntax error, switch to the function form above; it needs no special configuration. ::: ## Global config: `configureBlac` `configureBlac` sets app-wide defaults. Call it **once, early** (before any blocs are acquired) — typically at your app's entry point. It shallow-merges the partial config into the current globals, so you only pass the keys you want to change. ```ts twoslash import { configureBlac } from '@blac/core'; configureBlac({ maxInstancesPerType: 5000, maxEmitsPerSecond: 240, // allow 4x/frame for a 60fps animation bloc }); ``` The config object has four keys: the default `equality` function and three **circuit breakers**. ### Circuit breakers Circuit breakers are guardrails for the two failure modes that quietly destroy a BlaC app: leaks (instances or refs that are created but never disposed) and emit storms (a tight loop pushing high-frequency data through state). They cost nothing on the happy path and turn a silent freeze into a loud, actionable error. | Option | Guards against | Behavior when exceeded | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | `maxInstancesPerType` | Too many **live instances** of one class — almost always an unstable instance key (a fresh object/array passed as `args` every render, or a missing `static key`) leaking one instance per render | `acquire` **throws** | | `maxRefsPerInstance` | Too many **distinct refs** on one instance — consumer cleanup (e.g. a `useBloc` unmount `release`) never firing, so refs pile up without bound | `acquire` **throws** | | `maxEmitsPerSecond` | An **emit storm** — a tight loop (RAF/animation, or an effect emitting on every commit) pushing high-frequency changes that saturate subscribers, plugins, and the main thread | Dev-only `console.warn` once per instance; **never throws**; no-op in production | A few details that matter when tuning these: - `maxInstancesPerType` throws when the live-instance count for a class **reaches** the limit (`>=`). `maxRefsPerInstance` throws when an instance's distinct-ref count **exceeds** the limit (`>`). - `maxEmitsPerSecond` counts only **real** state changes (emits that actually changed state, after equality filtering) in a rolling one-second window, and warns at most once per instance. It is a heuristic — high-frequency state is occasionally legitimate, so it never blocks you. - **Disable any breaker** by setting it to `Infinity` or any non-positive value (`0`, `-1`). - **"Dev-only" means `process.env.NODE_ENV` is defined and not `'production'`.** A bundle with no `process` shim runs the production path (no per-emit bookkeeping), so if you want the warnings while developing, make sure your bundler defines `NODE_ENV` — Vite, Next and webpack do by default. :::caution[A thrown breaker is a symptom, not the bug] If `acquire` throws an instance- or ref-limit error, raising the limit usually just delays the freeze. The real cause is almost always an unstable key or a missing `release`. Stabilize the `args` / add a `static key` ([Passing Inputs](/guide/inputs)), or fix the missing cleanup ([Instance Management](/core/instance-management)), before reaching for a higher limit. ::: ### `equality` (global default) The global equality function applied to every bloc that doesn't declare its own via [`@blac({ equality })`](#equality-equalityfn). The exported default, `shallowEqualState`, short-circuits on `Object.is`, returns `false` for non-objects/`null`, and otherwise compares top-level keys with `Object.is`. Swap it to change the default skip behavior for the whole app: ```ts twoslash import { configureBlac, shallowEqualState } from '@blac/core'; // Treat any two states as different unless referentially identical configureBlac({ equality: (a, b) => Object.is(a, b) }); ``` ### Defaults | Option | Type | Default | What it does | | --------------------- | ------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `equality` | `EqualityFn` | `shallowEqualState` | App-wide default equality for `emit`/`update`; return `true` to skip the emit | | `maxInstancesPerType` | `number` | `100000` | Max live instances per class before `acquire` throws | | `maxRefsPerInstance` | `number` | `100000` | Max distinct refs per instance before `acquire` throws | | `maxEmitsPerSecond` | `number` | `1000` | Dev-only soft limit; real emits/sec per instance before a one-time `console.warn` | | `unownedSweepDelayMs` | `number` | `5000` | How long an instance created during a render may stay unowned (SSR, discarded or not-yet-committed renders) before it is disposed |
    Reading the current config (advanced) `getBlacConfig()` returns the live config object and `resetBlacConfig()` restores defaults. Both are marked `@internal` — `resetBlacConfig()` is handy in test teardown to undo a `configureBlac` from a previous test, but neither belongs in application code.
    ## See also - [Instance Management](/core/instance-management) — how `keepAlive`, ref counting, and instance keys drive sharing and disposal - [Passing Inputs](/guide/inputs) — `args`, `static key`, and the full instance-identity model - [Cubit](/core/cubit) — `emit` vs `update` vs `patch`, and where equality applies - [DevTools](/plugins/devtools) — how `excludeFromDevTools` is consumed --- # Cubit > A Cubit is the state container you subclass for almost everything in BlaC — typed state, emit/update/patch mutations, derived getters, and the args/deps lifecycle. Source: /core/cubit/ A Cubit is a state container that holds a typed state value and exposes methods to change it. It is the class you subclass for almost everything in BlaC. Every signature on this page is quoted from the `@blac/core` source. ```ts class Cubit< S extends object = any, Args = void, Deps extends object = Record, > extends StateContainer { emit(next: S): void; patch(partial: DeepPartial): void; update(fn: (state: S) => S): void; } ``` | Type parameter | Default | Description | | -------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `S` | `any` | The state shape. Must be an object type (`S extends object`); primitives like `number` or `string` are not supported as state. | | `Args` | `void` | Serializable construction data delivered to `init(args)`. See [Args](#args-typed-construction-data). | | `Deps` | `Record` | Non-serializable handles injected per consumer and read via `this.deps`. See [Deps](#deps-non-serializable-handles). | `Cubit` re-declares `emit`, `patch` and `update` as **public**; on `StateContainer` the three are `protected`. That is the difference between them: a `StateContainer` owns its own transitions and callers go through the methods it exposes, while a `Cubit` can also be driven from outside. Extend `Cubit` unless you specifically want mutation sealed inside the class. ## Why a class? (and why "Cubit", not "Bloc") BlaC has exactly two base types: `StateContainer` (the abstract engine) and `Cubit` (the concrete class you extend). There is **no `Bloc` class** — if you are coming from `flutter_bloc`, the closest equivalent is `Cubit`. We use the name "bloc" colloquially to mean "any state-container instance," but the only thing you ever extend is `Cubit`. See [Core Concepts](/guide/concepts/) for the full StateContainer / Cubit / bloc / instance hierarchy. The state lives in a **class** for three concrete reasons: - **State and the logic that changes it live together.** Methods like `addItem` sit next to the state they mutate, so an action is a method call, not a reducer plus an action-type constant plus a dispatch. - **Derived values are just getters.** A `get total()` is computed on read and tracked automatically — no selector library, no memo wiring (see [Getters](#getters-derived-state)). - **Instances have identity and a lifecycle.** The registry can create, key, ref-count, and dispose instances. That is what makes shared-vs-scoped state, `args`, and `deps` possible (see [Instance Management](/core/instance-management)). For the deeper rationale (why proxy tracking over selectors, why microtask batching, how this compares to Redux/Zustand/MobX), see the [Mental Model](/guide/mental-model). ## Constructor Define your state type as a generic parameter and pass the initial state to `super()`. ```ts constructor(initialState: S, options?: StructuralContainerOptions) ``` | Parameter | Type | Required | Description | | -------------- | ---------------------------- | -------- | ------------------------------------------------------------------------------------------------ | | `initialState` | `S` | yes | The starting state. Must be an object. | | `options` | `StructuralContainerOptions` | no | Low-level container options (custom per-path equality, skeleton hints). Most subclasses omit it. | **Returns:** a new instance. The registry always builds instances zero-arg, so your subclass constructor takes no arguments — it only calls `super(initialState)`. **Behavior.** Runs before `init(args)`. Use it solely to set the initial state; args-derived setup belongs in [`init`](#init-args). ```ts twoslash import { Cubit } from '@blac/core'; interface TodoState { items: string[]; filter: 'all' | 'active' | 'done'; } class TodoCubit extends Cubit { constructor() { super({ items: [], filter: 'all' }); } addItem = (text: string) => { this.update((s) => ({ ...s, items: [...s.items, text] })); }; setFilter = (filter: TodoState['filter']) => { this.patch({ filter }); }; } ``` :::tip[Why arrow-function fields for methods?] Notice `addItem` and `setFilter` are defined as arrow-function class fields, not regular methods. This binds `this` to the instance, so the method keeps working when passed as a callback — `onClick={cubit.addItem}` or destructured `const { addItem } = cubit`. A regular method would lose its `this` in those cases. Use arrow-function fields for any method you intend to pass around; getters and internal helpers can stay regular methods. ::: ## Mutation methods State in BlaC is **immutable from the outside**: you never assign to `this.state.x`. Instead you hand the container a _new_ state and it diffs the change, marks which paths moved, and wakes only the consumers that read those paths. The three methods below are three ways to produce that next state. :::note[Why immutable?] The diffing that powers smart re-renders (see [Dependency Tracking](/react/dependency-tracking)) needs a previous value and a next value to compare. Mutating in place would leave nothing to compare against, so a mutation would either re-render everything or nothing. Producing a new value on every change is what lets BlaC wake exactly the right consumers. ::: ### `emit(next)` Replace the entire state. Use when you have the full new state ready. ```ts emit(next: S): void ``` | Parameter | Type | Required | Description | | --------- | ---- | -------- | ------------------------------------------------------------------------------------- | | `next` | `S` | yes | The complete new state. Replaces the current state wholesale — it does **not** merge. | **Returns:** `void`. **Behavior.** `emit` is a no-op when the next state is equal to the current one: it short-circuits if `prev === next` by reference, or if the configured equality function (default: shallow per-key `Object.is`) reports them equal. So emitting an object that happens to match the current state will not wake any consumers. Equality is configurable per class via `blac({ equality })` and globally via `configureBlac` — see [Configuration](/core/configuration). ```ts twoslash import { Cubit } from '@blac/core'; interface CounterState { count: number; label: string; } class CounterCubit extends Cubit { constructor() { super({ count: 0, label: 'start' }); } reset = () => { this.emit({ count: 0, label: 'reset' }); // full replacement }; } ``` :::danger[Common mistake: emit with a partial object] `emit` _replaces_ the whole state — it does not merge. Passing a partial object silently drops every field you left out. If you only mean to change some fields, use `patch`, or read-and-spread with `update`. See [Troubleshooting](#emit-silently-drops-fields). ::: ### `update(fn)` Derive new state from the current state. Use when you need to read the current state to compute the next one. ```ts update(fn: (state: S) => S): void ``` | Parameter | Type | Required | Description | | --------- | ----------------- | -------- | ---------------------------------------------------------------------------- | | `fn` | `(state: S) => S` | yes | A reducer receiving the current state and returning the complete next state. | **Returns:** `void`. **Behavior.** `update` is sugar over `emit`: it calls `this.emit(fn(currentState))`, so it inherits `emit`'s equality short-circuit. Your `fn` must return the **full** next state — the same "no merge" rule as `emit` applies, so spread the existing state when you only change a few fields. ```ts twoslash import { Cubit } from '@blac/core'; class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } increment = () => { this.update((current) => ({ ...current, count: current.count + 1 })); }; } ``` ### `patch(partial)` Deep-merge partial changes into the current state. Use when you want to update some fields without touching others. ```ts patch(partial: DeepPartial): void ``` | Parameter | Type | Required | Description | | --------- | ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `partial` | `DeepPartial` | yes | The subtree to merge. Nested objects can be patched without spreading the full structure; arrays, `Date`, `Map`, `Set`, and class instances are treated as atomic leaves. | **Returns:** `void`. **Behavior.** `patch` deep-merges along plain-object branches and value-filters the result: a path is marked dirty only if its value actually changed. It skips the update entirely if all provided top-level values are already `Object.is`-equal to the current state (a shallow pre-spread no-op check), and `deepMerge` also returns the previous state by reference on a deep no-op. :::caution[patch ignores the equality function] The per-class/global equality function applies to `emit` and `update` only. `patch` does its own per-key/per-path `Object.is` filtering instead, so a custom `equality` will not change how `patch` decides what counts as a change. ::: ```ts twoslash import { Cubit } from '@blac/core'; interface ProfileState { loading: boolean; user: { profile: { name: string; age: number } }; } class ProfileCubit extends Cubit { constructor() { super({ loading: false, user: { profile: { name: '', age: 0 } } }); } startLoad = () => { this.patch({ loading: true }); }; rename = (name: string) => { // Only updates user.profile.name — age and other fields are preserved this.patch({ user: { profile: { name } } }); }; } ``` ### Choosing a method | Scenario | Method | | -------------------------- | -------- | | Full state replacement | `emit` | | Derived from current state | `update` | | Update a few fields | `patch` | | Toggle a boolean | `update` | | Reset to initial state | `emit` | ## Getters: derived state Getters are how you model **derived state** — values computed from other state rather than stored. Prefer a getter over storing a computed field: a stored `total` can drift out of sync with `items`, but a `get total()` is recomputed on every read and can never be stale. A getter has no signature fence of its own — it is plain TypeScript on your subclass. In React render, it participates in auto-tracking: :::tip[Getters auto-track in render] Reading a getter from the `bloc` returned by `useBloc` (`bloc.total`) records the `this.state` paths the getter touches. Name the getter in `select` only when the getter's return value should be the explicit re-render boundary: `useBloc(CartCubit, { select: (state, bloc) => [bloc.total] })`. See [Dependency Tracking](/react/dependency-tracking). ::: ```ts twoslash import { Cubit } from '@blac/core'; interface CartItem { price: number; } class CartCubit extends Cubit<{ items: CartItem[] }> { constructor() { super({ items: [] }); } get total() { return this.state.items.reduce((sum, item) => sum + item.price, 0); } get isEmpty() { return this.state.items.length === 0; } addItem = (item: CartItem) => { this.update((s) => ({ items: [...s.items, item] })); }; } ``` ```tsx function CartSummary() { const [, cart] = useBloc(CartCubit); // re-renders when items change because the getter reads this.state.items return Total: ${cart.total}; } ``` ## Lifecycle, args, and deps A Cubit instance moves through a fixed sequence, and the input hooks slot into specific points: 1. **Construct** — `new Type()` runs your constructor, which calls `super(initialState)`. The constructor takes no arguments; the registry always builds instances zero-arg. 2. **`init(args)`** — called **once**, synchronously, after construction and before any consumer reads the first snapshot. This is where args-derived setup belongs. 3. **`onDepsChanged(next, prev)`** — called whenever the merged per-consumer `deps` view changes (and once more on dispose, with everything cleared). 4. **Mutations** — `emit` / `update` / `patch` run as your methods are called. 5. **Dispose** — when the last consumer releases the instance (unless `keepAlive`); fires the `dispose` system event. Steps 2 and 3 are the two input lanes: **args** for serializable identity data, **deps** for live non-serializable handles. The rest of this section covers each. ### `init(args)` Seed args-derived state or kick off loads, once per instance, before the first snapshot. ```ts protected init(args: Args): void ``` | Parameter | Type | Required | Description | | --------- | ------ | -------- | ------------------------------------------------------------------------------------------------------- | | `args` | `Args` | yes | The args passed at acquire time (`useBloc(Type, { args })`). `void` when no `Args` generic is declared. | **Returns:** `void`. **Behavior.** A protected lifecycle method — not callable from outside the class. The framework calls it **once per instance**, synchronously after `new Type()` and before the first state snapshot is read. It replaces the old `setConfig`/`setProps` patterns. When `useBloc` is called with `{ args }`, those args are required at the call site (type error if omitted when `Args != void`), used to derive the instance identity (different args ⇒ different instance), and available synchronously in `init` before any consumer sees state. See [Passing Inputs](/guide/inputs) for the full args/identity model. ```ts twoslash import { Cubit } from '@blac/core'; interface UserCardState { name: string; loading: boolean; } class UserCardCubit extends Cubit { constructor() { super({ name: '', loading: false }); } // Constructor stays zero-arg. The framework calls init(args) before first snapshot. protected init(args: { userId: string }) { this.patch({ loading: true }); } // Side effects (fetch/subscribe/timer) belong here, not in init — see below. protected onActivate(signal: AbortSignal) { void this.loadUser(this.args!.userId); } private async loadUser(_id: string) { /* ...fetch... */ } } ``` ### `onActivate` / `onDeactivate` ```ts protected onActivate(signal: AbortSignal): void protected onDeactivate(): void ``` **Behavior.** `onActivate` fires on the instance's 0→1 ownership transition — the moment something first acquires it — and is the recommended place for side effects (fetch, subscribe, start a timer). It hands you an `AbortSignal` that is aborted when `onDeactivate` fires on the matching 1→0 transition, so cleanup is one line: ```ts twoslash import { Cubit } from '@blac/core'; interface UserCardState { name: string; } // ---cut--- class UserCardCubit extends Cubit { constructor() { super({ name: '' }); } protected onActivate(signal: AbortSignal) { fetch(`/api/users/${this.args!.userId}`, { signal }) .then((r) => r.json()) .then((user) => this.patch(user)); } } ``` `init` is **not** deprecated and keeps working exactly as documented above — it's still the right place to seed synchronous state from `args`. Only the recommendation for side effects moves: `onActivate` fires only when something actually owns the instance, so work started there is never wasted on a discarded or SSR-only render. :::caution[Ordering with `useBlocDeps`] In `@blac/react`, `onActivate` fires in a layout effect, before the first `useBlocDeps` slice is applied (which happens in a passive effect). A bloc reading `this.deps.x` inside `onActivate` sees `undefined` on first activation. Use `onDepsChanged` for deps-driven work instead — see [Passing Inputs](/guide/inputs). ::: ### `static key`: explicit identity declaration By default, instance identity is the structural hash of all args. Override with a static property on the class to control exactly which args distinguish one instance from another: ```ts static key: (args: Args) => string ``` **Behavior.** A function `(args: Args) => string` declared once on the class, not at every call site. When absent, BlaC hashes the full args object. Args not referenced by `key` ride along as config but do **not** fork instances. ```ts twoslash import { Cubit } from '@blac/core'; interface DocState { title: string; } class DocumentCubit extends Cubit< DocState, { docId: string; readonly: boolean } > { constructor() { super({ title: '' }); } static key = (args: DocumentCubit['args']) => args?.docId ?? ''; // `readonly` rides along as config but does NOT fork instances } ``` ### Deps: non-serializable handles Declare a `Deps` type as the third generic parameter to receive non-serializable values (refs, callbacks, controller instances) that can't go in `args`. Deps are read lazily via `this.deps.x` and may be `undefined` — always guard. ```ts get deps(): Readonly ``` **Returns:** a `Readonly` view — the union of every consumer's contributed slice. **Key properties of deps:** - **Never key identity** — different refs don't fork the instance. - **Per-consumer merged** — each `useBloc` call contributes its own slice; the bloc sees the union (last writer wins on key collisions). - **Live** — updated after each commit; may change over time. May legitimately be `undefined`, so always optional-chain. ```ts twoslash import { Cubit } from '@blac/core'; interface UploadState { progress: number; } interface InputRef { current?: { click?: () => void }; } class FileUploadCubit extends Cubit< UploadState, { endpoint: string }, // Args { inputRef?: InputRef } // Deps > { private endpoint = ''; constructor() { super({ progress: 0 }); } protected init(args: { endpoint: string }) { this.endpoint = args.endpoint; } openPicker() { this.deps.inputRef?.current?.click?.(); // lazy read, may be undefined } } ``` ### `onDepsChanged(next, prev)` For handles that require initialization when they arrive (a canvas element, a rich-text-editor controller), implement `onDepsChanged`. It fires after each deps merge with the new and previous combined views. ```ts protected onDepsChanged(next: Readonly, prev: Readonly): void ``` | Parameter | Type | Required | Description | | --------- | ---------------- | -------- | ----------------------------------------------------------------------------- | | `next` | `Readonly` | yes | The merged deps view after this reconcile. | | `prev` | `Readonly` | yes | The merged view before it. Compare the two to detect arrivals and departures. | **Returns:** `void`. **Behavior.** Optional — blocs that don't declare it just read `this.deps.x` lazily. When declared, it gives the bloc clean acquire/release edges without any consumer-side cleanup wiring. It also fires once on dispose with every key cleared (so `next.x` is `undefined`), giving you a teardown edge. ```ts twoslash import { Cubit } from '@blac/core'; interface RenderState { ready: boolean; } interface RteController { destroy(): void; } class CanvasRendererCubit extends Cubit< RenderState, { sceneId: string }, { canvas?: HTMLCanvasElement; controller?: RteController } > { constructor() { super({ ready: false }); } onDepsChanged(next: this['deps'], prev: this['deps']) { if (next.canvas && next.canvas !== prev.canvas) { this.initRenderer(next.canvas); } if (!next.canvas && prev.canvas) { this.disposeRenderer(); } if (next.controller !== prev.controller) { this.bindController(next.controller); } } private initRenderer(_c: HTMLCanvasElement) {} private disposeRenderer() {} private bindController(_c?: RteController) {} } ``` :::danger[Common mistakes with args and deps] - **Non-serializable value in `args`.** Args are hashed to derive instance identity; a function, ref, or class instance in `args` either throws (functions) or produces a fresh hash every render, spawning a new instance each time. Put non-serializable handles in `deps` instead. See [Troubleshooting](#a-new-instance-is-created-on-every-render). - **Two consumers writing the same `deps` key.** Deps are merged per consumer into one view. If two `useBloc` call sites both supply `deps.controller`, the bloc sees one of them (last writer for that key) — decide a single owner. - **Reading `this.deps.x` without guarding.** A dep may legitimately be `undefined` (no consumer has supplied it yet, or it unmounted). Always use optional chaining, as in `this.deps.inputRef?.current`. See [Passing Inputs](/guide/inputs) for the full identity/merge model and [Core Concepts](/guide/concepts/) for the judgment on which lane to reach for. ::: ## `depend(Type)` Declare a cross-bloc dependency. See [Bloc Communication](/core/bloc-communication) for the full pattern. ```ts protected depend( Type: T, defaultArgs?: ExtractArgs, ): DepHandle ``` | Parameter | Type | Required | Description | | ------------- | ------------------------------------- | -------- | --------------------------------------------------------------------------------------------------- | | `Type` | `T extends StateContainerConstructor` | yes | The state-container class to depend on. | | `defaultArgs` | `ExtractArgs` | no | Args identifying which keyed instance to resolve when an accessor is called without its own `args`. | **Returns:** a `DepHandle` with two accessors — `handle.untracked()` resolves the dep against the registry lazily on each call (`this.user.untracked()`), and `handle.track()` does the same _plus_ subscribes the reading React consumer. Both take an optional `{ args }` to override `defaultArgs` per call. See [Auto-tracking with `.track()`](/core/bloc-communication#auto-tracking-with-track). **Behavior.** Records the dependency, then returns the handle. Resolving on every call keeps the surface immune to dep-instance churn. Note: `.untracked()` does **not** auto-resubscribe to the dep's channel — consumers that need reactive updates use `.track()` (or subscribe explicitly via `useBloc`'s tracker). A naive always-on auto-bridge would cycle on mutual deps, which is why tracking is opt-in. ```ts twoslash import { Cubit } from '@blac/core'; class AuthCubit extends Cubit<{ userId: string | null }> { constructor() { super({ userId: null }); } } class ProfileCubit extends Cubit<{ name: string }> { private auth = this.depend(AuthCubit); constructor() { super({ name: '' }); } get currentUserId() { return this.auth.untracked().state.userId; // lazy resolve } } ``` ## Protected APIs These are available inside your Cubit class but not from the outside: - `this.state` — read the current state. - [`this.init(args)`](#init-args) — lifecycle called once before first snapshot (override when `Args` is declared). - [`this.onDepsChanged(next, prev)`](#ondepschanged-next-prev) — lifecycle called after each `deps` merge (override when `Deps` is declared). - `this.onSystemEvent(event, handler)` — listen to lifecycle events (see [System Events](/core/system-events)). - [`this.depend(OtherClass)`](#depend-type) — declare a dependency on another state container (see [Bloc Communication](/core/bloc-communication)). ## Public properties | Property | Type | Description | | ---------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------- | | `state` | `Readonly` | Current state value | | `$blac` | `BlacMeta` | Identity and lifecycle metadata. See the table below. | | `$blac.name` | `string` | Display name (defaults to class name) | | `$blac.id` | `string` | Unique instance identifier | | `$blac.createdAt` | `number` | Creation timestamp | | `$blac.disposed` | `boolean` | Whether the instance has been disposed | | `$blac.hydration` | `BlacHydration` | Hydration lifecycle object. See below. | | `$blac.hydration.status` | `HydrationStatus` | Current hydration phase (`'idle'` \| `'hydrating'` \| `'hydrated'` \| `'error'`); see [Persistence](/plugins/persistence) | | `$blac.hydration.isHydrated` | `boolean` | Shorthand: `status === 'hydrated'` | | `$blac.hydration.wait()` | `Promise` | Resolves once hydration settles; see [Persistence](/plugins/persistence) | ## Async methods Cubits handle async operations naturally. Model loading/error state explicitly and guard against stale responses: ```ts twoslash import { Cubit } from '@blac/core'; interface Article { id: string; } declare const api: { fetchArticles(category: string): Promise }; interface ArticleState { articles: Article[]; status: 'idle' | 'loading' | 'error' | 'success'; error: string | null; } class ArticleCubit extends Cubit { private requestId = 0; constructor() { super({ articles: [], status: 'idle', error: null }); } load = async (category: string) => { const id = ++this.requestId; this.patch({ status: 'loading', error: null }); try { const articles = await api.fetchArticles(category); if (id !== this.requestId) return; // stale response this.emit({ articles, status: 'success', error: null }); } catch (e) { if (id !== this.requestId) return; this.patch({ status: 'error', error: String(e) }); } }; } ``` The `requestId` pattern discards responses from superseded requests. Each new `load()` call increments the ID, and callbacks from previous calls see a mismatch and bail out. :::tip[Form validation pattern] Cubits work well for form state. Use `patch` for field updates and getters for validation: ```ts twoslash import { Cubit } from '@blac/core'; class FormCubit extends Cubit<{ email: string; password: string }> { constructor() { super({ email: '', password: '' }); } setEmail = (email: string) => this.patch({ email }); setPassword = (password: string) => this.patch({ password }); get errors() { const errors: Record = {}; if (!this.state.email.includes('@')) errors.email = 'Invalid email'; if (this.state.password.length < 8) errors.password = 'Too short'; return errors; } get isValid() { return Object.keys(this.errors).length === 0; } } ``` ::: ## See also - [Mental Model](/guide/mental-model) — why class-based containers, proxy tracking, and microtask batching - [Passing Inputs](/guide/inputs) — the full args / deps identity model ## Troubleshooting For the full FAQ see [Troubleshooting](/guide/troubleshooting). Below are the Cubit-specific problems. ### `emit` silently drops fields **Symptom:** After calling `emit`, some state fields are `undefined` even though you didn't intend to clear them. **Cause:** `emit` **replaces** the entire state — it does not merge. Passing a partial object drops every field you omit. **Fix:** Use `patch` for partial updates, or spread existing state in `update`: ```ts twoslash import { Cubit } from '@blac/core'; class TodoCubit extends Cubit<{ items: string[]; filter: 'all' | 'done'; }> { constructor() { super({ items: [], filter: 'all' }); } // state: { items: [...], filter: 'all' } bad = () => { // @ts-expect-error — items is required; emit replaces the whole state this.emit({ filter: 'done' }); // items would be undefined! }; // Fix A — patch merges only the listed fields fixA = () => this.patch({ filter: 'done' }); // Fix B — update reads current state first fixB = () => this.update((s) => ({ ...s, filter: 'done' })); } ``` See [Mutation methods: choosing a method](#choosing-a-method) above. ### A new instance is created on every render **Symptom:** DevTools shows a new bloc instance being created and destroyed on every render, or the circuit-breaker throws "max instances exceeded." **Cause:** A non-serializable value (function, ref, class instance) is in `args`. Because `args` must be JSON-serializable, a fresh object reference each render produces a different hash and therefore a different instance key. **Fix:** Move non-serializable values to the bloc's `Deps` lane — they never affect instance identity: ```tsx // Non-serializable in args → new instance every render (throws in dev) useBloc(UploadCubit, { args: { onComplete: () => {} } }); // Serializable identity in args, handle in deps useBloc(UploadCubit, { args: { endpoint: '/upload' } }); // (wire the callback via deps — see Cubit Deps and Passing Inputs) ``` See [Deps: non-serializable handles](#deps-non-serializable-handles) and [Troubleshooting: instance identity](/guide/troubleshooting#instance-identity-too-many--too-few). --- # Instance management > The registry is a global singleton that manages the lifecycle of state container instances — creation, sharing, ref counting, and disposal via acquire, ensure, borrow, and release. Source: /core/instance-management/ The registry is a global singleton that manages the lifecycle of state container instances. It handles creation, sharing, ref counting, and disposal. Every signature on this page is quoted from the `@blac/core` source. ## The mental model: a shared library with checkouts Think of the registry as a **lending library** for bloc instances: - Each `(class, instanceKey)` pair is one **book** on the shelf. Ask for the same pair twice and you get the same physical book — that is how two components share state without prop-drilling. - A consumer that needs a book **checks it out** (`acquire`) and **returns it** (`release`) when done. Every checkout is a **reference** (a "ref"). - The library keeps a **count of who has each book checked out**. When the count drops to zero, the book is **discarded** (`dispose`) — its state is gone and any timers or subscriptions it set up are torn down. - `keepAlive` marks a book as a permanent reference copy: it stays on the shelf even at zero checkouts. Two consequences fall straight out of this model: 1. **Sharing is automatic.** The first `acquire` of a key creates the instance; every later `acquire` of the same key reuses it. You never wire instances together by hand. 2. **Cleanup is automatic — but only if every checkout is returned.** A missing `release` is like never returning a library book: the count never reaches zero, the instance never disposes, and its memory (and subscriptions) leak. ## How ref counting works When you call `useBloc(CounterCubit)` in React: 1. The hook calls `acquire(CounterCubit)`, which either creates a new instance or returns the existing one. 2. The ref count increments (+1). Each consumer gets its own ref (a unique `refId`), so two components reading the same bloc count as two refs. 3. When the component unmounts, `release(CounterCubit)` decrements the ref count (-1). 4. At ref count zero, the instance is disposed (unless `keepAlive` is set). This means shared instances are automatically cleaned up when no component needs them. ```text acquire ───▶ ref count 1 (created, init() runs, 'created' fires) acquire ───▶ ref count 2 (same instance reused — shared) release ───▶ ref count 1 (still alive, one consumer remains) release ───▶ ref count 0 ─┬─ keepAlive? yes ─▶ stays alive └─ keepAlive? no ─▶ dispose() ├─ 'dispose' system event fires ├─ subscriptions/handlers torn down ├─ entry removed from registry └─ ensure-created deps with 0 refs cascade-dispose ``` :::note[When does disposal actually happen?] Disposal is **synchronous** with the `release` call that drops the count to zero — there is no idle delay or timer. The very next `acquire` of that key (e.g. a new component mounting) creates a **fresh** instance with fresh state. If you need state to persist across that gap, use [`keepAlive`](/core/configuration). ::: ## Registry functions ### `acquire(BlocClass, opts?)` Create or return an existing instance, incrementing the ref count. ```ts function acquire( BlocClass: T, opts?: { args?: ExtractArgs; refId?: string }, ): InstanceType; ``` | Parameter | Type | Required | Description | | ------------ | ------------------------------------- | -------- | ---------------------------------------------------------------------------------------------- | | `BlocClass` | `T extends StateContainerConstructor` | yes | The state-container class to acquire an instance of. | | `opts.args` | `ExtractArgs` | no | Serializable construction data passed to `init(args)`. Derives the instance key. | | `opts.refId` | `string` | no | Caller-supplied ref identifier. Used by `useBloc` to pair with `release`. Rarely set manually. | **Returns:** `InstanceType` — the live instance (newly created or existing). **Behavior.** The instance key is derived from `opts.args` (`static key` if declared, else the structural hash, else `'default'`). If no instance exists for the resolved key, `acquire` creates one, calls `init(args)`, and fires the internal `'created'` event. If an instance already exists, it is returned as-is. The ref count is always incremented. **Every `acquire` must have a matching `release`** — a missing `release` is the canonical instance leak. In React, `useBloc` owns this pair; call `acquire` directly only in server-side or scripting contexts. ```ts twoslash import { acquire, release } from '@blac/core'; import { Cubit } from '@blac/core'; class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } increment = () => this.update((s) => ({ count: s.count + 1 })); } // --- outside React --- const counter = acquire(CounterCubit); counter.increment(); console.log(counter.state.count); // => 1 release(CounterCubit); // must pair with acquire ``` ### `ensure(BlocClass, opts?)` Create or return an existing instance **without** taking a ref. ```ts function ensure( BlocClass: T, opts?: { args?: ExtractArgs }, ): InstanceType; ``` | Parameter | Type | Required | Description | | ----------- | ------------------------------------- | -------- | ------------------------------------------------------ | | `BlocClass` | `T extends StateContainerConstructor` | yes | The state-container class. | | `opts.args` | `ExtractArgs` | no | Construction data for `init(args)` and key derivation. | **Returns:** `InstanceType` — the live instance. **Behavior.** Like `acquire`, but takes **no ref** — the instance can be disposed by another caller while you hold the returned reference. Use `ensure` inside other cubits (via `depend()`) or in tooling that only needs the instance to exist without pinning its lifecycle. Because no ref is taken, no matching `release` is needed. ```ts twoslash import { ensure } from '@blac/core'; import { Cubit } from '@blac/core'; class AuthCubit extends Cubit<{ userId: string | null }> { constructor() { super({ userId: null }); } } // ensure creates AuthCubit if it's not alive yet, but does not keep it alive const auth = ensure(AuthCubit); console.log(auth.state.userId); // no release needed ``` ### `borrow(BlocClass, opts?)` Return an existing instance without taking a ref. Throws if the instance does not exist. ```ts function borrow( BlocClass: T, opts?: { args?: ExtractArgs }, ): InstanceType; ``` | Parameter | Type | Required | Description | | ----------- | ------------------------------------- | -------- | --------------------------------------------------------------- | | `BlocClass` | `T extends StateContainerConstructor` | yes | The state-container class. | | `opts.args` | `ExtractArgs` | no | Args identifying the instance. Defaults to the `'default'` key. | **Returns:** `InstanceType` — the live instance. **Behavior.** Does not create the instance if it is absent; throws an `Error` instead. Use `borrow` when the absence of an instance is a programming error — it makes the failure loud and immediate rather than silently returning `undefined`. No ref is taken, so no `release` is needed. ```ts twoslash import { borrow } from '@blac/core'; import { Cubit } from '@blac/core'; class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } } // Safe read-only access — no acquire/release pair needed function readCount(): number { return borrow(CounterCubit).state.count; } ``` ### `borrowSafe(BlocClass, opts?)` Return an existing instance without taking a ref. Returns an error object instead of throwing. ```ts function borrowSafe( BlocClass: T, opts?: { args?: ExtractArgs }, ): | { error: Error; instance: null } | { error: null; instance: InstanceType }; ``` | Parameter | Type | Required | Description | | ----------- | ------------------------------------- | -------- | --------------------------------------------------------------- | | `BlocClass` | `T extends StateContainerConstructor` | yes | The state-container class. | | `opts.args` | `ExtractArgs` | no | Args identifying the instance. Defaults to the `'default'` key. | **Returns:** `{ error: null; instance: InstanceType }` when the instance exists, or `{ error: Error; instance: null }` when it does not. **Behavior.** Identical to `borrow` but returns a discriminated union instead of throwing. Prefer `borrowSafe` in code paths where absence is expected (e.g. optional integrations) and `borrow` where absence is always a bug. ```ts twoslash import { borrowSafe } from '@blac/core'; import { Cubit } from '@blac/core'; class AuthCubit extends Cubit<{ userId: string | null }> { constructor() { super({ userId: null }); } } const { error, instance } = borrowSafe(AuthCubit); if (error) { console.log('Auth not initialized yet'); } else { console.log(instance.state.userId); } ``` ### `release(BlocClass, opts?)` Decrement the ref count. Dispose the instance when count reaches zero. ```ts function release( BlocClass: T, opts?: { args?: ExtractArgs; refId?: string; forceDispose?: boolean }, ): void; ``` | Parameter | Type | Required | Description | | ------------------- | ------------------------------------- | -------- | ----------------------------------------------------------------------------- | | `BlocClass` | `T extends StateContainerConstructor` | yes | The state-container class. | | `opts.args` | `ExtractArgs` | no | The args used when `acquire` was called. **Must resolve to the same key.** | | `opts.refId` | `string` | no | The ref identifier passed to `acquire`. Rarely set manually. | | `opts.forceDispose` | `boolean` | no | When `true`, dispose immediately even if `keepAlive` is set. Default `false`. | **Returns:** `void`. **Behavior.** `release` is **idempotent for an already-dropped ref** — releasing more times than you acquired won't throw, it just no-ops once the count is gone. The `args` you release with **must resolve to the same key** you acquired with; a mismatch leaves the original ref dangling forever. At ref count zero, the instance is disposed synchronously (unless `keepAlive` is set or `forceDispose` is `false`). ```ts twoslash import { acquire, release } from '@blac/core'; import { Cubit } from '@blac/core'; class SessionCubit extends Cubit<{ token: string | null }, { scope: string }> { static key = (a: { scope: string }) => a.scope; constructor() { super({ token: null }); } } const session = acquire(SessionCubit, { args: { scope: 'main' } }); // ... use session ... release(SessionCubit, { args: { scope: 'main' } }); // args must match acquire's ``` ## Named instances To manage distinct named instances, declare an `Args` shape and pass `args` to any registry function. The instance key is derived from those args — a `static key` if the class declares one, otherwise the structural hash of the args: ```ts twoslash import { acquire, release } from '@blac/core'; import { Cubit } from '@blac/core'; class EditorCubit extends Cubit<{ content: string }, { docId: string }> { static key = (a: { docId: string }) => a.docId; constructor() { super({ content: '' }); } } const editor1 = acquire(EditorCubit, { args: { docId: 'doc-42' } }); const editor2 = acquire(EditorCubit, { args: { docId: 'doc-99' } }); // These are different instances const areDifferent = editor1 !== editor2; // true release(EditorCubit, { args: { docId: 'doc-42' } }); release(EditorCubit, { args: { docId: 'doc-99' } }); ``` In React, pass the same `args`: ```tsx const [state] = useBloc(EditorCubit, { args: { docId: 'doc-42' } }); ``` ### Args derive identity `args` are the **only** way to get distinct instances — the meaningful value keys the instance _and_ feeds `init(args)`, so you never pass the same id twice: ```tsx // The meaningful value keys the instance AND feeds init(args) const [s] = useBloc(UserCardCubit, { args: { userId } }); ``` Identity precedence: own `args` (via `static key(args)`, else structural hash of `args`) > `` context args > `'default'`. The resolved key is the registry's single source of truth — `acquire` and `release` both run their `args` through the same resolution, so a ref taken under a given args key is dropped under the same key. See [Passing Inputs](/guide/inputs) for the full model and [Configuration](/core/configuration#-key-args--string-) for `static key`. ## Querying the registry ```ts twoslash import { hasInstance, getRefCount, getAll, forEach, getStats, } from '@blac/core'; import { Cubit } from '@blac/core'; class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } } const _a = hasInstance(CounterCubit); // boolean const _b = getRefCount(CounterCubit); // number of distinct active refs const _c = getAll(CounterCubit); // all instances of this class forEach(CounterCubit, (_inst) => {}); // iterate instances const _d = getStats(); // { registeredTypes, totalInstances, typeBreakdown } ``` ## Cleanup ```ts twoslash import { clear, clearAll } from '@blac/core'; import { Cubit } from '@blac/core'; class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } } clear(CounterCubit); // dispose and remove all instances of this class clearAll(); // dispose and remove everything ``` :::caution[`clear` / `clearAll` are teardown tools, not app code] `clear` and `clearAll` dispose instances **regardless of ref count**, ignoring `keepAlive`. That is exactly what you want between tests to isolate the registry, but in running app code it will pull state out from under live components. Reach for them in test setup/teardown — see [Testing core logic](/testing/core) — not in feature code. ::: ## In React vs outside React In React, `useBloc` handles `acquire` and `release` automatically. You rarely call registry functions directly. Outside React (tests, scripts, server-side code), manage the lifecycle manually: ```ts twoslash import { acquire, release } from '@blac/core'; import { Cubit } from '@blac/core'; class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } increment = () => this.update((s) => ({ count: s.count + 1 })); } const counter = acquire(CounterCubit); counter.increment(); console.log(counter.state.count); release(CounterCubit); ``` ## See also - [Configuration](/core/configuration) — `keepAlive`, `static key`, and the circuit breakers that catch leaks - [Passing Inputs](/guide/inputs) — `args` and the full instance-identity model - [System Events](/core/system-events) — the `dispose` event that fires when an instance is torn down - [Testing core logic](/testing/core) — using `clear`/`clearAll` to isolate the registry between tests ## Troubleshooting For the full FAQ see [Troubleshooting](/guide/troubleshooting). Below are the instance-management-specific problems. ### State unexpectedly resets on remount **Symptom:** A component unmounts and remounts and finds the bloc in its initial state — any previous state is gone. **Cause:** When the last consumer releases, the instance is disposed immediately (ref count 0 → dispose). The next consumer acquires a brand-new instance with fresh state. **Fix:** If state must persist across unmounts, mark the class `keepAlive`: ```ts twoslash import { blac, Cubit } from '@blac/core'; interface SessionState { token: string | null; } @blac({ keepAlive: true }) class SessionCubit extends Cubit { constructor() { super({ token: null }); } } ``` `keepAlive` instances are never auto-disposed at ref count 0 — tear them down explicitly with `release(Class, { forceDispose: true })` or `clear(Class)` in teardown. See [Configuration](/core/configuration#-keepalive-true-). ### Instance never disposed / memory leak **Symptom:** DevTools shows an instance that should have been disposed is still alive, or ref count never returns to 0. **Cause:** An `acquire` outside React has no matching `release`. Every call to `acquire` increments the ref count; without a matching `release` the count never reaches zero. **Fix:** Pair every `acquire` with a `release`, ideally in a `try/finally`. If you only need to read without owning the lifecycle, use `borrow` or `ensure` — neither takes a ref: ```ts twoslash import { acquire, borrow, release } from '@blac/core'; import { Cubit } from '@blac/core'; class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } } // Leaks — no release function readOnceBad() { const c = acquire(CounterCubit); return c.state.count; // BUG: ref count never drops to 0 } // Fixed — borrow takes no ref function readOnce() { return borrow(CounterCubit).state.count; } ``` ### Circuit breaker threw ("max instances" / "max refs") **Symptom:** `acquire` throws with a message like "exceeded the maximum live instances" or "exceeded the maximum refs." **Cause:** Almost always a leak — an unstable instance key (non-serializable `args`) spawning endless instances, or a missing `release` accumulating refs. The limit is not too low; the leak is real. **Fix:** Find and fix the leak before raising the limit. Common causes: non-serializable values in `args` (fix: move to `deps`), `acquire` without `release` (fix: pair them), args that differ structurally each render (fix: normalise types). See [Configuration: circuit breakers](/core/configuration#circuit-breakers) and [Troubleshooting](/guide/troubleshooting#instance-identity-too-many--too-few). --- # Plugin Authoring > A plugin installs once, globally, and observes lifecycle events across every state container — the escape hatch for cross-cutting concerns like logging, DevTools, persistence, and analytics. Source: /core/plugins/ Some behavior does not belong to any single bloc — logging every state change, mirroring state into DevTools, persisting to IndexedDB, sending analytics. Wiring that into each bloc by hand would scatter the same concern across your codebase and couple your domain logic to infrastructure. A **plugin** is the escape hatch: it installs once, globally, and observes lifecycle events across _every_ state container in the registry. Write the cross-cutting concern once; it applies everywhere automatically. :::note[Plugins vs system events — which do I reach for?] [System events](/core/system-events) (`this.onSystemEvent(...)`) are for logic that belongs to **one bloc** ("when _this_ cart changes, recompute _this_ total"). Plugins are for behavior that applies **across all blocs** ("log every state change in the app"). If you find yourself adding the same `onSystemEvent` handler to many blocs, that is the signal to lift it into a plugin. ::: ## The BlacPlugin interface A plugin is a plain object: a `name`, a `version`, and any subset of the optional hooks below. Implement only what you need — every hook is optional. ```ts twoslash import { type BlacPlugin, type PathSet, ALL_PATHS } from '@blac/core'; const myPlugin: BlacPlugin = { name: 'my-plugin', version: '1.0.0', // Fires once when the plugin is installed. // ctx.container is undefined here — onInstall is global, not per-bloc. onInstall(ctx) {}, // Fires once when the plugin is uninstalled. No context. onUninstall() {}, // Fires when any state container is first created and acquired. // The new container is ctx.container. onCreated(ctx) {}, // Fires once per microtask flush after any state change. // prev/next are the coalesced before/after states; paths is the // set of changed property paths (or ALL_PATHS for a full change). onStateChange(ctx, prev, next, paths: PathSet) {}, // Fires when any state container is disposed. // The disposed container is ctx.container ($blac.disposed is already true). onDestroyed(ctx) {}, // Fires on every hydration status transition of any container. onHydrationChange(ctx, status, previousStatus) {}, // Fires when a container gains its first owner. The signal aborts when // the container is deactivated or disposed — use it to clean up. onActivate(ctx, signal) {}, // Fires when a container loses its last owner but is not disposed // (keepAlive containers). Not called on dispose — use onDestroyed. onDeactivate(ctx) {}, }; ``` :::caution[The container is on `ctx`, not a separate parameter] Every per-container hook receives the focal bloc as `ctx.container`, not as a second argument. There is no `instance` parameter. This is a common mistake when porting from older drafts — `onCreated(ctx, instance)` is wrong; use `ctx.container`. ::: ### Hook signatures (verbatim) | Hook | Signature | When it fires | | ------------------- | ------------------------------- | ------------------------------------------------- | | `onInstall` | `(ctx)` | Once at install. `ctx.container` is `undefined`. | | `onUninstall` | `()` | Once at uninstall. No context. | | `onCreated` | `(ctx)` | Synchronously when a container is first acquired. | | `onStateChange` | `(ctx, prev, next, paths)` | Once per microtask flush after a state change. | | `onDestroyed` | `(ctx)` | Synchronously after a container is disposed. | | `onHydrationChange` | `(ctx, status, previousStatus)` | On each hydration status transition. | | `onActivate` | `(ctx, signal)` | When a container gains its first owner. | | `onDeactivate` | `(ctx)` | When it loses its last owner, without disposal. | :::note[Activation is ownership, not lifetime] `onActivate` / `onDeactivate` track whether anything currently holds a reference, and both are idempotent — a second owner does not re-activate. For an ordinary container, losing the last owner disposes it, so you get `onDestroyed`; `onDeactivate` fires only for containers that survive at zero owners (`keepAlive`) and may later re-activate with a fresh signal. Disposal aborts the signal without firing `onDeactivate`, so teardown has exactly one path. :::
    Internal devtools-only hooks The interface also declares `onRefAcquired(ctx, refId)`, `onRefReleased(ctx, refId)`, and `onDepsChanged(ctx, previousDeps, currentDeps)`. These are marked `@internal` and exist to support the DevTools connector; they are not part of the stable plugin contract and may change. Avoid them in application plugins.
    ## Installing a plugin `getPluginManager()` returns the `PluginManager` bound to the global registry. Call it once, near app startup, to install your plugins. Plugins only observe the registry they are installed on. For a scoped registry (for example one passed to `RegistryProvider`), install them on that registry's manager with `getPluginManager(registry)`. ```ts import { getPluginManager } from '@blac/core/plugins'; getPluginManager().install(myPlugin, { enabled: true, environment: 'development', // 'development' | 'production' | 'test' | 'all' }); ``` | Config option | Default | Meaning | | ------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `enabled` | `true` | Set `false` to register the plugin but skip all hook dispatch. | | `environment` | `'all'` | Active only when `process.env.NODE_ENV` matches (`'development'`, `'production'`, `'test'`) or `'all'`. A mismatch logs a skip and installs nothing. | The `environment` gate is a runtime check against `NODE_ENV`, not tree-shaking. Use `'development'` for debug-only plugins (logging, DevTools) so they add zero overhead in production; use `'all'` for plugins you genuinely want everywhere, like persistence. :::caution[Common mistakes] - **`install` throws if a plugin with the same `name` is already installed.** Names are the unique key; pick a stable, unique `name`. - **If `onInstall` throws, the plugin is rolled back** (removed) and the error rethrown — a failed install leaves nothing half-registered. - **Forgetting `environment` means a debug plugin runs in production.** A noisy logging plugin left at the default `'all'` will log in prod. Gate it to `'development'`. ::: ## Uninstalling `uninstall` takes the plugin's **name string**, not the plugin object. It throws if no plugin with that name is installed. ```ts getPluginManager().uninstall('my-plugin'); ``` Other `PluginManager` methods: `getPlugin(name)`, `getAllPlugins()`, `hasPlugin(name)`, `clear()` (uninstall all), and `destroy()` (clear plus detach the registry lifecycle hooks). ## PluginContext The `ctx` parameter (always first) carries the focal container plus safe, read-only access to registry data. A fresh `ctx` is built per dispatch — it is cheap and closes over the registry. - **`ctx.container`** — the bloc this event is about (`StateContainer | undefined`). It is `undefined` only inside `onInstall`. For per-container hooks (`onCreated`, `onStateChange`, `onDestroyed`, `onHydrationChange`) it is the focal bloc. Use `ctx.container` to read `state`, `interner`, `$blac.id`, etc. The methods below take an `instance` argument so a plugin can also reach blocs other than the focal one (e.g. via `queryInstances`): | Method | Returns | | ------------------------------------- | ----------------------------------------------------------------------------------- | | `getInstanceMetadata(instance)` | `{ id, className, isDisposed, name, state, createdAt, args, hydrationStatus, ... }` | | `getState(instance)` | Current state of the instance | | `getHydrationStatus(instance)` | Current `HydrationStatus` of the instance | | `startHydration(instance)` | Begin hydration for the instance | | `applyHydratedState(instance, state)` | Apply restored state during hydration | | `finishHydration(instance)` | Mark hydration as complete | | `failHydration(instance, error)` | Mark hydration as failed | | `waitForHydration(instance)` | `Promise` that resolves when hydration completes | | `queryInstances(Type)` | All instances of a given class | | `getAllTypes()` | All registered state container classes | | `getStats()` | `{ registeredTypes, totalInstances, typeBreakdown }` | | `getRefIds(instanceId)` | Array of ref holder IDs for an instance | The five hydration methods — `startHydration`, `applyHydratedState`, `finishHydration`, `failHydration`, and `waitForHydration` — exist so a plugin can drive the [hydration lifecycle](/core/system-events) from the outside. The [Persistence plugin](/plugins/persistence) is their real-world consumer: it begins hydration, restores saved state, and finishes (or fails) hydration through exactly these methods. ## PathSet and the path interner :::tip[You probably do not need this] Most plugins act on the whole `next` state and ignore `paths` entirely. Reach for the interner only when you are building field-level tooling — structured logs or DevTools integrations that report _which_ property changed. ::: `onStateChange` receives `paths: PathSet` as its fourth argument: a `Set` of the property paths that changed in the flush — **or the `ALL_PATHS` sentinel** when the change spans every path (for example a change to a container with no registered consumers, which skips path diffing). It is never `undefined`. A `PathId` is an integer, not a string. Each BlaC class has its own **per-class `PathInterner`** that maps path strings (`"items"`, `"user.profile.name"`) to those integers. Interning to integers keeps the hot path (diffing and set intersection on every flush) cheap — comparing numbers beats comparing strings. To turn a `PathId` back into a readable string, call `interner.lookup(pathId)` on the **focal container's** interner, reachable via `ctx.container`: ```ts twoslash import { type BlacPlugin, type PathSet, ALL_PATHS } from '@blac/core'; const pathLoggingPlugin: BlacPlugin = { name: 'path-logger', version: '1.0.0', onStateChange(ctx, prev, next, paths) { const container = ctx.container; if (!container) return; if (paths === ALL_PATHS) { console.log(`[${container.$blac.name}] changed: (all paths)`); return; } for (const pathId of paths) { const path = container.interner.lookup(pathId); console.log(`[${container.$blac.name}] changed: ${path}`); } }, }; ``` :::caution[Always handle `ALL_PATHS` before iterating] `paths` is a union of `Set` and the `ALL_PATHS` symbol. Iterating the symbol with `for..of` throws. Check `paths === ALL_PATHS` first, every time. The interner lives on the container (`ctx.container.interner`), not on the metadata object returned by `getInstanceMetadata` — that metadata has no `interner` field. ::: ## A complete minimal plugin Here is an end-to-end plugin you can install as-is. It logs creation and disposal, and reports each state change with its changed paths — a self-contained template you can adapt for analytics, audit logs, or telemetry. ```ts twoslash import { getPluginManager } from '@blac/core/plugins'; import { type BlacPlugin, type PathSet, ALL_PATHS } from '@blac/core'; const auditPlugin: BlacPlugin = { name: 'audit-log', version: '1.0.0', onCreated(ctx) { const c = ctx.container; if (c) console.log(`[audit] created ${c.$blac.name} (${c.$blac.id})`); }, onStateChange(ctx, _prev, _next, paths) { const c = ctx.container; if (!c) return; const changed = paths === ALL_PATHS ? ['(all)'] : [...paths].map((id) => c.interner.lookup(id)); console.log(`[audit] ${c.$blac.name} changed:`, changed); }, onDestroyed(ctx) { const c = ctx.container; if (c) console.log(`[audit] disposed ${c.$blac.name} (${c.$blac.id})`); }, }; // Install once at startup. Gate to development so it adds no prod overhead. getPluginManager().install(auditPlugin, { environment: 'development' }); ``` :::caution[Common mistakes] - **Mutating state inside `onStateChange`.** Calling `ctx.container.emit(...)` (or `patch`/`update`) from this hook triggers another flush, which fires `onStateChange` again — an infinite loop. Plugins observe; they do not write back. - **Skipping the `ALL_PATHS` check.** See the warning above — iterating the sentinel throws. - **Leaving a debug plugin at `environment: 'all'`.** It will run (and log, and cost) in production. Gate noisy plugins to `'development'`. :::
    Performance note: plugins run on every flush To deliver `onStateChange` for every flush, the manager subscribes to each container's channel with `ALL_PATHS` interest, so every flush of every container runs the plugin bridge. It does not register as a consumer and does not change how paths are diffed. The trade-off is intentional — DevTools and persistence genuinely need every change — but it is why a plugin you do not need should stay uninstalled or `environment`-gated rather than installed-but-disabled.
    ## `onStateChange` is once-per-flush `onStateChange` fires **once per microtask flush**, not once per individual `emit`/`patch`/`update` call. If a method calls `patch` three times synchronously, `onStateChange` receives a single call with the final state and the union of all changed paths. Two consequences: - `prev` is snapshotted **once per flush** (the state before the first emit of that flush) and reused for every plugin, so the value a plugin sees is independent of install order. - For true per-call granularity (rare), subscribe to the container's channel directly instead of using a plugin hook. This batching mirrors `onSystemEvent('stateChanged')` inside a bloc — the same microtask-coalescing model, seen from the global side rather than the per-instance side. See [System Events](/core/system-events) for the per-instance view and the full rationale for why BlaC batches. ## Built-in plugins These official plugins are themselves authored against the interface above — read their source as worked examples: - [Logging](/plugins/logging) — console logging and monitoring - [DevTools](/plugins/devtools) — Chrome DevTools integration - [Persistence](/plugins/persistence) — IndexedDB state persistence (the real consumer of the hydration methods on `ctx`) ## See also - [Plugin Overview](/plugins/overview) — the plugin catalog and where to start - [System Events](/core/system-events) — per-instance lifecycle hooks and when to use them instead - [Persistence](/plugins/persistence) — a complete plugin that drives the hydration API - [Core Concepts](/guide/concepts/) — definitions for plugin, hydration, interner, and `PathSet` --- # channel.subscribe > The lowest-level way to observe a bloc — register a callback on a container's channel that fires when a dirty region overlaps your declared interest. For plugins, devtools, and infrastructure. Source: /core/subscribe/ The lowest-level way to observe a bloc. Every container owns a `channel` — a `DirtyChannel` from the DirtyTalk engine — and `channel.subscribe(interest, cb)` registers a callback that fires when the channel flushes a dirty region that overlaps your declared interest. Almost no application code needs this. It exists for plugins, devtools, and infrastructure that must compose path-scoped subscriptions directly on a container. If you are inside a React component, use [`useBloc`](/react/use-bloc); if you are outside React, use [`watch`](/core/watch). `watch` is itself a thin wrapper around `channel.subscribe(() => ALL_PATHS, ...)`. :::note[Where this sits in the stack] `channel` is inherited from `StructuralContainer` (the structural layer under every `Cubit`). The same `subscribe` shape is also exposed one level up as a pass-through `container.subscribe(...)` (which internally delegates to `container.channel.subscribe(...)`). This page documents the channel method directly, because that is the surface plugins compose against. ::: ## Stability `channel`, `ALL_PATHS`, and the `PathSet` type are **public** — `@blac/core` re-exports `ALL_PATHS` (value) and `PathSet` (type) specifically so plugins can compose channel subscriptions. The mechanism for minting a _specific_ `PathSet` (the `PathInterner` and its numeric `PathId`s) is **`@internal`**: it is not re-exported from `@blac/core` and its representation can change between releases. In practice that means the only stable, portable interest you can construct from outside the framework is `ALL_PATHS` (observe everything). Path-scoped interests are an internal optimization that `useBloc`'s render tracker builds for itself. ## Signature ```ts // On the channel (DirtyChannel), from packages/dirtytalk-engine/src/dirty-channel.ts:58 subscribe(interest: () => Region, cb: (dirty: Region) => void): () => void ``` For a bloc's channel, `Region` is instantiated as `PathSet`: ```ts // container.channel is DirtyChannel, so subscribe reads as: subscribe(interest: () => PathSet, cb: (dirty: PathSet) => void): () => void ``` | Parameter | Type | Description | | ---------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `interest` | `() => PathSet` | A thunk returning the region this subscriber cares about. Re-evaluated lazily, once per flush per subscriber — **not** snapshotted at subscribe time. Skipped entirely on empty-dirty flushes. Return `ALL_PATHS` to be woken by every change. | | `cb` | `(dirty: PathSet) => void` | Invoked with the accumulated dirty region whenever it intersects `interest()`. The bloc's _new_ state is already applied when this runs — read `container.state` inside the callback. | | returns | `() => void` | An idempotent unsubscribe closure. Safe to call more than once, and safe to call from inside the callback mid-flush. | ## The interest / PathSet shape ```ts // packages/dirtytalk-structural/src/path-set.ts const ALL_PATHS: unique symbol; type AllPaths = typeof ALL_PATHS; type PathSet = Set | AllPaths; // packages/dirtytalk-structural/src/types.ts type PathId = number; ``` A `PathSet` is either: - the **`ALL_PATHS`** sentinel — "every path", i.e. wake on any change; or - a `Set` of interned path ids — a specific set of fields. `PathId`s are interned per container **class** by an internal `PathInterner`; you do not construct them by hand, and the interner is not part of the public `@blac/core` surface. Because of that, the portable interest from application/plugin code is `ALL_PATHS`. The `Set` form is what `useBloc`'s render tracker assembles internally to get fine-grained re-renders — see [Tracking](/core/tracked). ## The callback contract - **Coalesced, asynchronous.** The channel uses a microtask scheduler by default, so several synchronous `emit`/`patch`/`update` calls fold into a **single** flush. Your callback does not fire once per mutation; it fires once per flush, after the current tick. See [System Events](/core/system-events) for the batching model. - **Interest is a thunk, re-run each flush.** The channel calls `interest()` lazily on every non-empty flush, then delivers only if `intersects(interest(), dirty)` is true. It is not captured once at subscribe time. - **No immediate fire.** Unlike `watch`, `channel.subscribe` does **not** invoke the callback once on setup. It only runs on the next flush after a matching change. If you need the current value immediately, read `container.state` yourself. - **Errors are collected, not swallowed silently.** A throw from your callback (or your interest thunk) is recorded; after all subscribers run, a single error re-throws as-is, and multiple throw as an `AggregateError`. ## Unsubscribe `subscribe` returns an unsubscribe function. Calling it marks the subscriber dead and removes it from the channel. The closure is **idempotent** — calling it twice is a no-op — and is safe to call from inside the callback (e.g. for one-shot subscriptions). Forgetting to call it leaks the subscription for the life of the bloc. ```ts twoslash import { Cubit, ALL_PATHS } from '@blac/core'; import { ensure } from '@blac/core'; class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } increment() { this.patch({ count: this.state.count + 1 }); } } const counter = ensure(CounterCubit); // Subscribe to ALL changes on this bloc's channel. const unsubscribe = counter.channel.subscribe( () => ALL_PATHS, // interest thunk: re-run each flush (dirty) => { // `dirty` is the PathSet that changed; the new state is already applied. console.log('counter changed ->', counter.state.count); }, ); counter.increment(); counter.increment(); // both fold into ONE coalesced flush next microtask // Later — tear down. Idempotent. unsubscribe(); unsubscribe(); // safe no-op ``` :::caution[No JSX here] This is the vanilla, outside-React surface. Do not reach for `channel.subscribe` inside a component — use [`useBloc`](/react/use-bloc), which builds a path-scoped interest for you and re-renders only on the fields you read. ::: ## Inside React vs outside React `channel.subscribe` is the floor of a three-tier API. Pick the highest tier that fits: | Where you are | Use | What you get | | --------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------ | | Inside a React component | [`useBloc`](/react/use-bloc) | Auto-tracked, path-scoped re-renders; lifecycle tied to mount. | | Outside React (logging, sync, imperative UI, tests) | [`watch`](/core/watch) | The bloc **instance**, fires once immediately, multi-bloc, `watch.STOP`. | | Building a plugin / devtools / infra | `channel.subscribe` | Raw `PathSet` deltas, no instance sugar, no immediate fire. | In short: **inside React → `useBloc`; outside React → `watch`** for almost everything, and **`channel.subscribe`** only when you are composing on the channel itself. `watch` exists precisely so you rarely touch the channel directly — it is `channel.subscribe(() => ALL_PATHS, ...)` plus instance resolution, an immediate fire, and a `STOP` sentinel. ## See also - [watch](/core/watch) — the outside-React wrapper around this method; prefer it unless you need raw channel access - [Tracking](/core/tracked) — how `useBloc` builds a fine-grained `Set` interest so it re-renders only on the fields you read - [System Events](/core/system-events) — the microtask flush and coalescing model behind every channel callback - [DirtyTalk](/dirtytalk/) — the underlying channel and the `Space`/`Scheduler` model it is built on --- # System events > Lifecycle hooks inside a single state container instance — react to its own state changes, disposal, or hydration status via onSystemEvent, without external wiring. Source: /core/system-events/ System events are lifecycle hooks **inside a single state container instance**. Use them to react to that instance's own state changes, disposal, or hydration status — from within the class, without any external wiring. Every signature on this page is quoted from the `@blac/core` source. **Why they exist:** sometimes a bloc needs to do something in response to its own lifecycle — tear down a timer when it's disposed, kick off a derived computation when its state settles, log a transition. `onSystemEvent` is the in-class hook for exactly that. It is the per-instance counterpart to [plugins](/core/plugins), which observe the same kinds of events but across _every_ instance (see [the comparison below](#system-events-vs-plugins)). ## Available events ```ts twoslash type SystemEvent = 'stateChanged' | 'dispose' | 'hydrationChanged'; ``` ## `onSystemEvent(event, handler)` Register a handler for a lifecycle event on this instance. ```ts protected onSystemEvent = ( event: E, handler: SystemEventHandler, ): (() => void) ``` | Parameter | Type | Required | Description | | --------- | -------------------------- | -------- | -------------------------------------------------------------------------------------- | | `event` | `SystemEvent` | yes | The event name: `'stateChanged'`, `'dispose'`, or `'hydrationChanged'`. | | `handler` | `SystemEventHandler` | yes | Callback receiving the event payload. The payload shape differs per event (see below). | **Returns:** `() => void` — an unsubscribe function. Call it to remove the handler before the instance is disposed. For handlers that should live as long as the instance, you can discard the return value — all handlers are torn down automatically on `dispose`. **Behavior.** A protected method — only callable inside the class. Handlers added for the same event are fired in registration order. Registering in the constructor (or in `init`) ensures handlers are wired before the instance starts emitting. `dispose` tears down all registered handlers automatically, so no manual cleanup is needed for instance-lifetime handlers. :::tip[Where to register handlers] Register handlers in the constructor (or in `init`) so they're wired before the instance starts emitting. For handlers with a lifetime shorter than the instance (e.g. conditional subscriptions), hold onto the returned unsubscribe function and call it when done. ::: ```ts twoslash import { Cubit } from '@blac/core'; class MyCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); this.onSystemEvent('stateChanged', ({ state, previousState }) => { console.log('State changed:', previousState, '->', state); }); this.onSystemEvent('dispose', () => { console.log('Instance disposed'); }); } } ``` ## Event payloads The handler argument differs by event. The payload types are: ```ts interface SystemEventPayloads { stateChanged: { state: S; previousState: S }; dispose: void; hydrationChanged: { status: HydrationStatus; previousStatus: HydrationStatus; error?: Error; changedWhileHydrating: boolean; }; } ``` ### `stateChanged` Fired once per **microtask flush** after state changes via `emit`, `update`, or `patch`. Multiple synchronous mutations are coalesced — the handler receives the final state once, not once per call. | Payload field | Type | Description | | --------------- | ---- | --------------------------------------------- | | `state` | `S` | The final state after the flush. | | `previousState` | `S` | The state before any mutations in this flush. | :::note[Why once-per-flush?] A single user action often triggers several `emit`/`patch` calls in a row. If `stateChanged` fired on each one, every handler (and every subscriber and plugin downstream) would run multiple times for one logical change, with `previousState` pointing at transient in-between values nobody cares about. BlaC instead **coalesces** all synchronous mutations into one flush on the next microtask: `previousState` is the state before the _first_ mutation, `state` is the state after the _last_. That keeps handlers cheap and consistent, and it's the same batching model plugins' `onStateChange` and [`watch`](/core/watch) callbacks ride on. The trade-off: your handler runs **asynchronously**, just after the current synchronous code finishes — not inside the `emit` call. ::: ```ts twoslash import { Cubit } from '@blac/core'; class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); this.onSystemEvent('stateChanged', ({ state, previousState }) => { // state: the final state after the flush // previousState: the state before any mutations in this flush console.log(`count: ${previousState.count} -> ${state.count}`); }); } increment = () => this.update((s) => ({ count: s.count + 1 })); } ``` ### `dispose` Fired when the instance is disposed (ref count reaches zero or `dispose()` is called directly). This is your hook to release anything the instance set up that the registry can't clean up for you. **Payload:** `void` — no payload; the instance is already disposed when this fires. :::tip[The dispose hook is your `componentWillUnmount`] If a bloc starts a `setInterval`, opens a WebSocket, or subscribes to something external in its constructor/`init`, the `dispose` event is where you stop it. The registry disposes the instance the moment its last consumer releases (see [Instance Management](/core/instance-management)); without a `dispose` handler, those external resources keep running after the bloc is gone. ::: ```ts twoslash import { Cubit } from '@blac/core'; class PollingCubit extends Cubit<{ data: string | null }> { private timer: ReturnType | null = null; constructor() { super({ data: null }); this.timer = setInterval(() => { this.patch({ data: new Date().toISOString() }); }, 1000); this.onSystemEvent('dispose', () => { if (this.timer !== null) { clearInterval(this.timer); this.timer = null; } }); } } ``` ### `hydrationChanged` Fired when the hydration status changes. Relevant when using the [Persistence plugin](/plugins/persistence). | Payload field | Type | Description | | ----------------------- | ---------------------- | ----------------------------------------------------------------------- | | `status` | `HydrationStatus` | The new status: `'idle'` \| `'hydrating'` \| `'hydrated'` \| `'error'`. | | `previousStatus` | `HydrationStatus` | The status before the change. | | `error` | `Error` \| `undefined` | Present when `status` is `'error'`. | | `changedWhileHydrating` | `boolean` | `true` if state was modified before hydration completed. | **Returns:** `void`. `changedWhileHydrating` is the signal that the bloc emitted real state _while_ async hydration was in flight — the persistence layer uses it to decide whether the just-loaded persisted state is stale and should be discarded. See [Persistence](/plugins/persistence) for the full hydration lifecycle. ```ts twoslash import { Cubit } from '@blac/core'; class StoredCubit extends Cubit<{ value: string }> { constructor() { super({ value: '' }); this.onSystemEvent( 'hydrationChanged', ({ status, previousStatus, error, changedWhileHydrating }) => { if (status === 'error' && error) { console.error('Hydration failed:', error.message); } if (changedWhileHydrating) { console.warn( 'State changed before hydration finished — persisted state may be stale', ); } console.log(`${previousStatus} -> ${status}`); }, ); } } ``` ## Unsubscribing `onSystemEvent` returns an unsubscribe function: ```ts twoslash import { Cubit } from '@blac/core'; class ExampleCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); const unsub = this.onSystemEvent('stateChanged', ({ state }) => { console.log(state); }); // Call unsub() to remove the handler early — before the instance is disposed. // For instance-lifetime handlers you can discard the return value. unsub(); } } ``` You only need to call this for handlers with a lifetime shorter than the instance (e.g. one registered conditionally). Handlers that should live for the whole instance need no manual cleanup — `dispose` tears them all down. ## System events vs plugins Both observe lifecycle, and they share the same coalesced-flush model — the difference is **scope** and **where the code lives**: | | System events | Plugins | | ------------ | --------------------------------------- | ------------------------------------------ | | **Scope** | Single instance | All instances | | **Access** | Inside the class (`this.onSystemEvent`) | Global via `getPluginManager()` | | **Use case** | Instance-specific side effects | Cross-cutting concerns (logging, devtools) | Use system events for cleanup logic, derived computations, or side effects that belong to **one specific instance**. Use [plugins](/core/plugins) for behavior that applies **across all** state containers. A useful way to see the relationship: a `stateChanged` system handler and a plugin's `onStateChange` hook fire off the _same_ underlying flush. The system event is the narrow, in-class view (this instance only, payload `{ state, previousState }`); the plugin hook is the broad, external view (every instance, plus the changed `paths` and a rich `PluginContext`). Reach for whichever scope matches the job — don't install a plugin to do one instance's cleanup, and don't try to make a system handler observe the whole app. :::caution[Common mistakes] **Don't emit from inside a `stateChanged` handler.** Mutating this bloc's state inside its own `stateChanged` handler schedules another flush, which fires the handler again — an unbounded feedback loop. (BlaC's dev-only emit-rate breaker will eventually `console.warn`, but the loop is still a bug.) Derive values without emitting, or move the write behind a guard that can't re-trigger. **Don't do heavy synchronous work in `stateChanged`.** It runs on every flush; expensive work there compounds with frequent updates. Debounce, or move the work into the action that caused the change. **Don't rely on `stateChanged` firing synchronously.** It's microtask-deferred, so code reading a side effect immediately after `emit()` won't see it yet. If you need the value right away, read `this.state` directly instead of waiting on the event. ::: ## See also - [Plugins](/core/plugins) — the same lifecycle hooks observed across all instances - [watch](/core/watch) — observe a bloc's changes from outside the class (rides the same flush) - [Instance Management](/core/instance-management) — what triggers the `dispose` event - [Persistence](/plugins/persistence) — the real-world consumer of `hydrationChanged` --- # Tracking > How BlaC records which leaves of state each consumer reads via a recording proxy, then wakes only the consumers whose leaves actually moved. Source: /core/tracked/ 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](/react/dependency-tracking); for the design rationale, see the [Mental Model](/guide/mental-model). :::caution[Tracking is automatic — there is nothing to opt into] Tracking is **automatic and unconditional**: there is no `tracked()` helper, no `@tracked` decorator, no `autoTrack` option, and nothing to opt into. The recording happens for you whenever a consumer reads state. To see which paths trigger re-renders at runtime, use the [BlaC DevTools](/plugins/devtools). To narrow or opt out of tracking in React, use the `select` option (see below). ::: ## What tracking is 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 read-recording proxy ### `trackRender(state, interner)` The internal function that wraps state in a recording `Proxy` for each consumer render. ```ts function trackRender(state: S, interner: PathInterner): TrackResult; ``` | Parameter | Type | Required | Description | | ---------- | -------------- | -------- | -------------------------------------------------------------------------------------------------------------- | | `state` | `S` | yes | 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` — an object `{ value: S, paths: Set }` 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 is per item** | `for..of`, `.map`, `.find`, `.reduce` record `users.length` and each field the callback reads (`users.0.name`, …). A row component reading `users[i]` wakes only when that item changes. | | **`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. | :::danger[Common mistake: non-plain values are not deeply tracked] Only plain object literals and arrays are proxied. If you store a `Map`, `Set`, `Date`, or a class instance in state and mutate it in place, no path is marked and nothing re-renders. Replace the value with a new instance instead: ```ts twoslash import { Cubit } from '@blac/core'; class TagCubit extends Cubit<{ tags: Set }> { constructor() { super({ tags: new Set() }); } // Won't re-render — same reference, no path marked // (never mutate state directly anyway — state is immutable) // this.state.tags.add('new'); ← wrong // Will re-render — new reference at the `tags` path addTag = (tag: string) => { this.update((s) => ({ tags: new Set([...s.tags, tag]) })); }; } ``` ::: ## Getters auto-track during render 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: ```ts twoslash import { Cubit } from '@blac/core'; interface CartItem { price: number; } class CartCubit extends Cubit<{ items: CartItem[]; coupon: string | null }> { get total() { // During render, this records `items` through the consumer's proxy. return this.state.items.reduce((sum, i) => sum + i.price, 0); } } ``` So a render can read the getter directly: ```tsx function Total() { const [, cart] = useBloc(CartCubit); return {cart.total}; } ``` Use `select` when you want the getter's return value, rather than its source paths, to gate re-renders: ```tsx function Total() { const [, cart] = useBloc(CartCubit, { select: (state, cart) => [cart.total], }); return {cart.total}; } ``` See [Dependency Tracking](/react/dependency-tracking#what-does-not-register-a-dependency) for the full list of what does and doesn't register. ## 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. :::note[Tracking outside React] Auto-tracking via the recording proxy is a **React render-time** feature, driven by `useBloc`. The lower-level container API (`channel.subscribe(interest, cb)`) lets you declare an interest set directly. The convenience helper [`watch`](/core/watch) deliberately does **not** auto-track: it subscribes to _all_ paths and fires on every change. If you need selective observation outside React, subscribe to the channel with an explicit interest, or filter inside your callback. ::: ## How tracking relates to re-renders 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](/core/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](/react/dependency-tracking#the-select-escape-hatch)). ## See also - [Dependency Tracking](/react/dependency-tracking) — the React-facing rules, `select`, and limitations - [Performance](/react/performance) — splitting readers from writers and avoiding over-reads - [Mental Model](/guide/mental-model) — why proxy tracking beats selectors and memoization - [watch](/core/watch) — observing state outside React ## Troubleshooting For the full FAQ see [Troubleshooting](/guide/troubleshooting). Below are tracking-specific problems. ### 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: ```ts twoslash import { Cubit } from '@blac/core'; class TagCubit extends Cubit<{ tags: Set }> { constructor() { super({ tags: new 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 addTag = (tag: string) => { this.update((s) => ({ tags: new Set([...s.tags, tag]) })); }; } ``` ### 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`: ```tsx function Total() { const [, cart] = useBloc(CartCubit); return ${cart.total}; } ``` See [Getters auto-track during render](#getters-auto-track-during-render) above and [Performance: Getters as computed properties](/react/performance#pattern-getters-as-computed-properties). --- # Types > The complete type toolkit exported from @blac/core — utilities to derive state, args, deps, and instance shapes from a container class, plus branded-ID helpers. Source: /core/types/ The complete type toolkit exported from `@blac/core`. These utilities let you derive the state, args, deps, and instance shapes of a container class without hand-writing them, and the branded-ID helpers give you nominal `InstanceId` strings. Every signature on this page is quoted verbatim from source. For a task-oriented walkthrough of typing blocs (generic parameters, inference, common pitfalls) see [TypeScript](/guide/typescript). This page is the reference: one heading per export, the exact signature, what it does, and a small example. :::note[Where these come from] The extraction and instance utilities live in `@blac/core`'s `types/utilities` module; the branded-ID helpers live in `types/branded`. Both are re-exported from the package root, so you import everything from `@blac/core` directly. ::: All examples assume this setup, which is hidden from the rendered snippet but type-checked: ```ts twoslash import { Cubit } from '@blac/core'; interface CounterState { count: number; label: string; } class CounterCubit extends Cubit { constructor() { super({ count: 0, label: 'idle' }); } } ``` ## Extracting type parameters A `StateContainer` (and therefore `Cubit`) carries three type parameters: `S` (state), `Args` (serializable construction/identity data), and `Deps` (injected non-serializable handles). These utilities pull each one back out of a container _class_ (the constructor, e.g. `typeof CounterCubit`), so you never have to re-declare a state shape that already lives on the class. ### `ExtractState` **Signature** ```ts export type ExtractState = T extends StateContainerConstructor ? Readonly : never; ``` Extracts the state type from a container constructor as a `Readonly`. This is the type you get back from `useBloc` and from the `state` getter — read-only, because state is immutable from the outside. Resolves to `never` if `T` is not a container constructor. ```ts twoslash import { Cubit } from '@blac/core'; import type { ExtractState } from '@blac/core'; interface CounterState { count: number; label: string; } class CounterCubit extends Cubit { constructor() { super({ count: 0, label: 'idle' }); } } // ---cut--- type S = ExtractState; // ^? ``` ### `ExtractStateMutable` **Signature** ```ts export type ExtractStateMutable = T extends StateContainerConstructor ? S : never; ``` The same as `ExtractState`, but without the `Readonly<>` wrapper — the raw `S` as the class declared it. Reach for this only when you genuinely need a mutable view (for example, building the next state object before you `emit` it); prefer `ExtractState` everywhere a value is being read. ```ts twoslash import { Cubit } from '@blac/core'; import type { ExtractStateMutable } from '@blac/core'; interface CounterState { count: number; label: string; } class CounterCubit extends Cubit { constructor() { super({ count: 0, label: 'idle' }); } } // ---cut--- type Draft = ExtractStateMutable; // ^? ``` ### `ExtractArgs` **Signature** ```ts export type ExtractArgs = T extends new () => StateContainer< any, infer A, any > ? A : void; ``` Extracts the `Args` type — the serializable data a bloc is constructed/identified with (see [Passing Inputs](/guide/inputs)). Falls back to `void` when the class declares no args. Note the match is against a zero-argument constructor shape (`new () =>`), which is how container subclasses are written. ```ts twoslash import { Cubit } from '@blac/core'; import type { ExtractArgs } from '@blac/core'; // ---cut--- interface UserState { name: string; } class UserCubit extends Cubit { constructor() { super({ name: '' }); } } type A = ExtractArgs; // ^? ``` ### `ExtractDeps` **Signature** ```ts export type ExtractDeps = T extends new () => StateContainer< any, any, infer D > ? D : Record; ``` Extracts the `Deps` type — the injected non-serializable handles (clients, services, other blocs) a container depends on. Falls back to `Record` (the "no deps" shape) when none are declared. ```ts twoslash import { Cubit } from '@blac/core'; import type { ExtractDeps } from '@blac/core'; // ---cut--- interface FeedState { posts: string[]; } class FeedCubit extends Cubit { constructor() { super({ posts: [] }); } } type D = ExtractDeps; // ^? ``` ## Instance and constructor types These describe the _instance_ a container class produces, and the _constructor_ shape that `@blac/core` and `@blac/react` use to type their entry points. ### `InstanceReadonlyState` **Signature** ```ts export type InstanceReadonlyState = Omit, 'state'> & { state: ExtractState }; ``` The instance type of a container class with its `state` property narrowed to the `Readonly` state for that class. Useful when you want the full instance (methods, getters) but with a precisely-typed, read-only `state`. ```ts twoslash import { Cubit } from '@blac/core'; import type { InstanceReadonlyState } from '@blac/core'; interface CounterState { count: number; label: string; } class CounterCubit extends Cubit { constructor() { super({ count: 0, label: 'idle' }); } } // ---cut--- type ReadonlyInstance = InstanceReadonlyState; declare const c: ReadonlyInstance; c.state.count; // ^? ``` ### `InstanceState` **Signature** ```ts export type InstanceState = Omit< InstanceType, 'state' > & { state: ExtractStateMutable }; ``` Like `InstanceReadonlyState`, but the `state` property is the _mutable_ `S` rather than `Readonly`. Reach for it only when you specifically need a writable view of state on a typed instance. ```ts twoslash import { Cubit } from '@blac/core'; import type { InstanceState } from '@blac/core'; interface CounterState { count: number; label: string; } class CounterCubit extends Cubit { constructor() { super({ count: 0, label: 'idle' }); } } // ---cut--- type MutableInstance = InstanceState; ``` ### `StateContainerInstance` **Signature** ```ts export type StateContainerInstance = Omit< StateContainer, 'state' > & { state: Readonly }; ``` A `StateContainer` instance keyed by its _state_ type `S` rather than by a concrete class, with `state` narrowed to `Readonly`. Use it when you want to describe "any container holding this state shape" without pinning a specific subclass. ```ts twoslash import type { StateContainerInstance } from '@blac/core'; // ---cut--- interface CounterState { count: number; label: string; } function readCount(c: StateContainerInstance) { return c.state.count; } ``` ### `StateContainerConstructor` **Signature** ```ts export type StateContainerConstructor = new ( ...args: any[] ) => StateContainer; ``` The minimal constructor type for a container class, parameterized by state `S`. It is the constraint the extraction utilities (`ExtractState`, `ExtractStateMutable`, `InstanceReadonlyState`, `InstanceState`) match against. Use it where you only care that something is a container class. ```ts twoslash import { Cubit } from '@blac/core'; import type { StateContainerConstructor } from '@blac/core'; interface CounterState { count: number; label: string; } class CounterCubit extends Cubit { constructor() { super({ count: 0, label: 'idle' }); } } // ---cut--- const Ctor: StateContainerConstructor = CounterCubit; ``` ## Branded IDs BlaC tags instance-identity strings with a _brand_ so a plain `string` cannot be passed where an `InstanceId` is expected. The brand is a compile-time-only phantom property keyed by a `unique symbol`; it has no runtime footprint — branded values are just strings at runtime. ### `Brand` **Signature** ```ts declare const brand: unique symbol; export type Brand = T & { [brand]: B }; ``` The general nominal-typing helper: intersects a base type `T` with a phantom property under a private `unique symbol`, tagged by the brand identifier `B`. Two `Brand`s with different `B` are incompatible even when `T` is identical, which is what prevents accidental mixing of similar primitive types. ```ts twoslash import type { Brand } from '@blac/core'; // ---cut--- type UserId = Brand; type OrderId = Brand; declare const u: UserId; declare function loadOrder(id: OrderId): void; // @errors: 2345 loadOrder(u); ``` ### `BrandedId` **Signature** ```ts export type BrandedId = Brand; ``` A convenience alias for the common case of branding a `string`: `BrandedId` is exactly `Brand`. Use it whenever the base type is a string ID. ```ts twoslash import type { BrandedId } from '@blac/core'; // ---cut--- type SessionId = BrandedId<'SessionId'>; ``` ### `InstanceId` **Signature** ```ts export type InstanceId = Brand; ``` The branded `string` type BlaC uses for state-container instance identities — `BrandedId<'InstanceId'>` by another name. Registry and identity APIs accept and return this rather than a bare `string`, so an arbitrary string cannot stand in for an instance key by accident. ```ts twoslash import type { InstanceId } from '@blac/core'; // ---cut--- declare function lookup(id: InstanceId): void; // @errors: 2345 lookup('not-branded'); ``` ### `instanceId()` **Signature** ```ts export function instanceId(id: string): InstanceId { return id as InstanceId; } ``` The value-level helper that brands a plain `string` as an [`InstanceId`](#instanceid). It is a pure cast at runtime (returns the input unchanged); its only job is to give you a typed `InstanceId` to hand to APIs that require one, without writing the `as InstanceId` assertion yourself. ```ts twoslash import { instanceId } from '@blac/core'; import type { InstanceId } from '@blac/core'; // ---cut--- const id: InstanceId = instanceId('user-42'); ``` ## See also - [TypeScript](/guide/typescript) — typing blocs end to end: generics, inference, and pitfalls - [Cubit](/core/cubit) — the class these utilities extract types from - [Instance Management](/core/instance-management) — `acquire` / `borrow` / `release` and the registry surface - [Passing Inputs](/guide/inputs) — the args / deps identity model that `ExtractArgs` and `ExtractDeps` read - [useBloc](/react/use-bloc) — where `ExtractState` shows up as the hook's return type --- # watch > Observe one or more blocs outside React and run a callback whenever their state changes — the escape hatch for loggers, analytics, localStorage sync, imperative UI, and test assertions. Source: /core/watch/ `useBloc` connects a bloc to a React component. But state often needs to reach code that has no component to render: a logger, an analytics pipeline, a `localStorage` sync, a `` driven by an imperative library, or a test assertion. `watch` is the escape hatch for those cases — it observes one or more blocs **outside of React** and runs a callback whenever their state changes. :::note[watch fires on every change, not only on what you read] Unlike `useBloc`, `watch` does **not** auto-track which properties your callback reads. It subscribes to the bloc's full state and invokes your callback on _every_ change (and once immediately). If you need to react only to a specific field, compare it yourself inside the callback, or use the lower-level `channel.subscribe(interest, cb)` API. Auto-tracking is a React render-time feature — see [Tracking](/core/tracked). ::: ## `watch(bloc, callback)` Observe one or more blocs outside React and run a callback on every state change. ```ts // Single bloc function watch( bloc: T | BlocRef, callback: (bloc: InstanceType) => void | typeof watch.STOP, ): () => void; // Multiple blocs function watch( blocs: T, callback: (blocs: ExtractInstances) => void | typeof watch.STOP, ): () => void; ``` | Parameter | Type | Required | Description | | ---------------- | ------------------------------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bloc` / `blocs` | `T \| BlocRef` or `readonly BlocInput[]` | yes | A bloc class (resolves to the default instance), an [`instance(Class, id)`](#watching-a-named-instance) reference, or a `readonly` array of either. | | `callback` | `(bloc: InstanceType) => void \| typeof watch.STOP` | yes | Runs once immediately with the current instance(s), then on every subsequent state change. Return `watch.STOP` to tear down the subscription from inside the callback. | **Returns:** a `stop()` function. Calling it unsubscribes and is idempotent — calling it more than once is safe. **Behavior.** `watch` resolves each input from the registry via `ensure` — creating the instance if it does not exist yet. It does **not** hold a ref: `watch` will not keep an otherwise-unused bloc alive, and a bloc it created can be disposed if nothing else references it. Subscriptions are coalesced per microtask flush — several synchronous mutations produce a single callback run — so callbacks land asynchronously after `emit()`. The callback fires **once immediately** on setup with the current state, then again after every subsequent change. ```ts twoslash import { watch } from '@blac/core'; import { Cubit } from '@blac/core'; class UserCubit extends Cubit<{ name: string }> { constructor() { super({ name: 'Alice' }); } } const stop = watch(UserCubit, (user) => { console.log('User state changed:', user.state.name); }); // later — e.g. in a cleanup or on teardown of your module: stop(); ``` ## `instance(BlocClass, args?)` Create a reference to a specific keyed bloc instance for use with `watch`. ```ts function instance( BlocClass: T, args?: ExtractArgs, ): BlocRef; ``` | Parameter | Type | Required | Description | | ----------- | ------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | | `BlocClass` | `T extends StateContainerConstructor` | yes | The bloc class to reference. | | `args` | `ExtractArgs` | no | The args identifying the instance — the same args you pass to `useBloc`. Resolved to a key via `static key`/structural hash. | **Returns:** a `BlocRef` — a lightweight reference object that tells `watch` which keyed instance to resolve. **Behavior.** By default `watch(SomeCubit, ...)` resolves the **default** instance. Wrap with `instance(Class, args)` to target the instance keyed by those args. ```ts twoslash import { watch, instance } from '@blac/core'; import { Cubit } from '@blac/core'; class UserCubit extends Cubit<{ name: string }, { userId: string }> { static key = (a: { userId: string }) => a.userId; constructor() { super({ name: '' }); } } const stop = watch(instance(UserCubit, { userId: 'user-123' }), (user) => { console.log(user.state.name); }); ``` ## Stopping a watch A watch lives until you stop it. There are two ways, and forgetting both is the most common `watch` bug. ### Call the returned stop function ```ts twoslash import { watch } from '@blac/core'; import { Cubit } from '@blac/core'; class UserCubit extends Cubit<{ name: string }> { constructor() { super({ name: '' }); } } const stop = watch(UserCubit, (user) => { console.log(user.state.name); }); // later — e.g. in a cleanup, useEffect teardown, or on teardown of your module: stop(); ``` ### Return `watch.STOP` from the callback For one-shot or self-terminating watches, return the `watch.STOP` sentinel and the subscription tears itself down: ```ts twoslash import { watch } from '@blac/core'; import { Cubit } from '@blac/core'; class UserCubit extends Cubit<{ onboardingComplete: boolean }> { constructor() { super({ onboardingComplete: false }); } } declare function runOnce(): void; watch(UserCubit, (user) => { if (user.state.onboardingComplete) { runOnce(); return watch.STOP; // stop after the condition is met } }); ``` :::danger[Common mistake: leaking the watcher] A `watch` you never stop keeps its subscription forever, holding a reference to the bloc and re-running your callback for the life of the process. Always either store and call `stop()`, or return `watch.STOP` once the work is done. Inside React, prefer `useBloc` over `watch`; if you must `watch` from an effect, return `stop` from the effect: ```tsx import { useEffect } from 'react'; import { watch } from '@blac/core'; // (in a React component) useEffect(() => { // const stop = watch(AuthCubit, (auth) => persistToken(auth.state.token)); // return stop; // tear down on unmount }, []); ``` ::: ## Watching multiple blocs Pass a `readonly` array to observe several blocs at once — the callback fires when **any** of them changes. ```ts twoslash import { watch } from '@blac/core'; import { Cubit } from '@blac/core'; class UserCubit extends Cubit<{ name: string }> { constructor() { super({ name: '' }); } } class SettingsCubit extends Cubit<{ theme: string }> { constructor() { super({ theme: 'light' }); } } declare function syncToServer(name: string, theme: string): void; // Watch several blocs at once; the callback fires when any of them changes. const stop = watch([UserCubit, SettingsCubit] as const, ([user, settings]) => { syncToServer(user.state.name, settings.state.theme); }); ``` ## When to use watch | Scenario | Use | | ---------------------------------------------------- | ------------------------------------------- | | A React component needs state | [`useBloc`](/react/use-bloc) | | Non-React side effects (logging, analytics, syncing) | `watch` | | Bridging a bloc to imperative / non-React UI | `watch` | | Test assertions on state over time | `watch` | | You need selective, path-scoped observation | `container.channel.subscribe(interest, cb)` | ## Examples ### Logging state changes ```ts twoslash import { watch } from '@blac/core'; import { Cubit } from '@blac/core'; interface CartItem { price: number; } class CartCubit extends Cubit<{ items: CartItem[] }> { constructor() { super({ items: [] }); } get total() { return this.state.items.reduce((sum, i) => sum + i.price, 0); } } declare const logger: { info(msg: string, data: unknown): void }; const stop = watch(CartCubit, (cart) => { logger.info('cart changed', { itemCount: cart.state.items.length, total: cart.total, // getters work here too }); }); ``` ### Bridging to non-React code Drive an imperative library (a chart, a map, a canvas) from bloc state without a React wrapper: ```ts twoslash import { watch } from '@blac/core'; import { Cubit } from '@blac/core'; class MetricsCubit extends Cubit<{ series: number[] }> { constructor() { super({ series: [] }); } } declare function createChart(el: HTMLElement): { setData(series: number[]): void; destroy(): void; }; function attachChart(el: HTMLElement) { const chart = createChart(el); const stop = watch(MetricsCubit, (metrics) => { chart.setData(metrics.state.series); }); // hand the caller a teardown that stops the watch and the chart return () => { stop(); chart.destroy(); }; } ``` ## `watch` vs `channel.subscribe` Both observe a single container outside React; both fire once per microtask flush. The differences: - **`watch`** gives you the **instance** (so getters and methods are available), accepts multiple blocs, supports `instance()` references and the `watch.STOP` sentinel, and fires once immediately on setup. - **`channel.subscribe`** is the lower-level path-scoped surface on a container's channel. You pass an _interest_ thunk (the paths you care about, or `ALL_PATHS` for everything) and a callback that fires when a matching path changes. It does not fire on setup and gives you the changed path set rather than the instance — read `state` off the container yourself. Prefer `watch` for whole-state observation; reach for `channel.subscribe` when you need path-scoped interest. ```ts twoslash import { watch, ensure, ALL_PATHS } from '@blac/core'; import { Cubit } from '@blac/core'; class UserCubit extends Cubit<{ name: string }> { constructor() { super({ name: '' }); } } const user = ensure(UserCubit); // channel.subscribe: path-scoped, fires on change (not on setup) const unsub = user.channel.subscribe( () => ALL_PATHS, () => { console.log(user.state.name); }, ); // watch: the instance, fires immediately, supports STOP and multiple blocs const stop = watch(UserCubit, (u) => { console.log(u.state.name); }); ``` ## See also - [System Events](/core/system-events) — the microtask flush and coalescing model behind `watch` - [Tracking](/core/tracked) — why `watch` does not auto-track, and how `useBloc` does - [Dependency Tracking](/react/dependency-tracking) — the React side of selective re-renders --- # Dependency tracking > How BlaC auto-tracking records the state properties a component reads and re-renders only when one of those properties changes. Source: /react/dependency-tracking/ When a component subscribes to a state container, the naive contract is "re-render whenever the state changes." That contract is wasteful: a `UserCubit` holding `name`, `email`, and `avatarUrl` would re-render your avatar component every time the email changes, even though the avatar never reads it. BlaC fixes this with **auto-tracking**: it records which state properties each component actually reads during render, and re-renders that component only when one of _those_ properties changes. You write plain property access; the re-render scope is inferred for you — no selectors, no `useMemo`, no manual dependency arrays. :::note[Mental model] Auto-tracking, dependency tracking, and "proxy tracking" all name the same mechanism. The feature is **dependency tracking**; the default mode is **auto-tracking**; the implementation is a render-time recording **Proxy**. For the design rationale (why a proxy beats hand-written selectors), see [Mental Model](/guide/mental-model). ::: ## Auto-tracking (default) The `state` value returned by `useBloc` is a `Proxy` that records every property your component reads during render. The recorded set becomes the component's re-render scope. ### Signature ```ts // useBloc with no `select` option — auto-tracking is active function useBloc( BlocClass: T, options?: Omit, 'select'>, ): [ state: ExtractState, bloc: InstanceReadonlyState, ref: RefObject, ]; ``` **Returns:** `[state, bloc, ref]` where `state` is a tracking Proxy and `bloc` is a per-consumer proxy for the instance. Any property accessed through `state` during render is recorded as a dependency. Getters read through `bloc` during render also record their underlying `this.state` reads. The component re-renders only when a recorded path changes. **Behavior.** 1. During render, the Proxy records each property access as a **path** (`state.avatarUrl` → `avatarUrl`). 2. After the render commits, BlaC registers that path set with the container as this consumer's interest. 3. On a state change, the container diffs which paths actually changed and wakes only the consumers whose recorded paths intersect the change. 4. Tracking is recomputed on **every** render — if your component conditionally reads different properties next time, the tracked set adapts to match. Each `useBloc` call gets its own proxy and its own recorded path set, so two components reading the same container are isolated from each other's re-renders. ```tsx function UserAvatar() { const [state] = useBloc(UserCubit); return ; // records: ['avatarUrl'] // changes to state.name, state.email, etc. do NOT re-render this component } ``` **Try it live.** Two components share one bloc. Each reads a different field — change one, only its reader re-renders: ### What DOES register a dependency | Read pattern | Records | Notes | | ---------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------- | | `state.name` | `name` | Direct property access. | | `state.user.profile.name` | `user.profile.name` | Nested reads record the **leaf** path only. | | `state.items[0]` | `items.0` | Indexed access on an array. | | `state.items.length` | `items.length` | `.length` is an own property read. | | Reading the whole object: `const u = state.user` (no deeper key) | `user` | Wakes on **any** change to `user`. | | Getter read in render: `bloc.total` | getter source paths | The returned bloc is proxied; `this.state` inside the getter uses the tracking proxy. | :::tip[Sibling-leaf isolation] Recording is **leaf-only**: reading `state.user.name` records `user.name`, not `user`. So when an immutable update replaces the `user` object because a _sibling_ (`user.address`) changed, a component that only read `user.name` does **not** re-render — its observed leaf still resolves to the same value. Read the whole `user` object (with no deeper key) and you opt back into waking on any change beneath it. ::: ### What does NOT register a dependency These are the patterns newcomers expect to track but don't — or that track more or less than intended. :::caution[Common mistakes] - **Destructuring is just reading** — `const { name } = state` records `name` (good). But `const { user } = state; return user.name` records only `user` (the deeper `.name` read happens off the _raw_ destructured value, not the proxy), so you wake on every `user` change. Read through the proxy chain (`state.user.name`) to get leaf isolation. - **Spreading reads everything** — `` or `{ ...state }` enumerates every own key, so the component tracks the **entire** state object and re-renders on any change. Pass only the fields you need. - **Reading inside an effect does not track** — the proxy records during _render_. Anything you read inside `useEffect`, an event handler, or an async callback runs after the render proxy is gone and records nothing. Read the value in the render body (or capture it from `state` there) if it should drive re-renders. - **Iteration coarsens to the entry path** — `.map`, `.find`, `.reduce`, `for..of` record the array path (`items`) but **not** per-index paths, and their callbacks receive raw (un-proxied) values. A list re-renders when the array changes, not per item. See [Performance](/react/performance#list-rendering-patterns) for the per-item isolation pattern. - **Non-plain values are leaves, not deeply tracked** — only plain objects `{}` and arrays `[]` are proxied. A `Map`, `Set`, `Date`, or class instance in your state is treated as a single leaf: BlaC detects a _reference_ change at that value's own path, but reading _into_ it (`state.cache.get(id)`) records nothing finer. Replace the whole value on update, or model that data as plain objects/arrays. - **Methods read off the prototype don't record** — reading `bloc.increment` without calling it records nothing (methods live on the prototype, not as own properties). This is fine: action handlers shouldn't drive re-renders. - **Getter reads outside render do not track** — a getter read in an effect, event handler, async callback, or after commit sees live state but records no dependency. Read the getter in the render body if it should drive re-renders, or use `select: (state, bloc) => [bloc.total]` when you want the getter's return value to be the explicit boundary. ::: ### Conditional reads Auto-tracking re-records on every render, which makes most conditional reads correct automatically. The condition itself must be read for the adaptive behavior to work: ```tsx function UserInfo() { const [state] = useBloc(UserCubit); return (
    {state.name} {state.showEmail && {state.email}}
    ); } ``` This is correct. `state.showEmail` is **always** read, so it's always tracked. While `showEmail` is `false`, `email` is not read and not tracked — but when `showEmail` flips to `true`, the component re-renders (it tracked `showEmail`), the new render reads `email`, and `email` joins the tracked set from that point on. :::caution[Where conditional reads break] The hazard is reading a property behind a condition that is **never re-evaluated by a tracked change**. If the deciding value comes from a prop or another hook (not from this bloc's tracked state), flipping it may not re-run the read against the latest state. When the branch and the value both come from the same bloc's state, you're safe. When the deciding value is external, prefer `select` to make the dependency explicit. ::: ## Passing state values as props A value read from `state` is a tracking proxy, not a plain object. Passing one down as a prop to a child that does not call `useBloc` itself works, but the child's reads are attributed to the **parent** that created the proxy — the parent is what re-renders when that path changes, and the parent's tracked set grows to cover whatever the child read. ```tsx function Parent() { const [state] = useBloc(ListBloc); // Row's reads of `item` land in Parent's tracked set. return ; } ``` For a memoized child this defeats the memo: the child re-renders because the parent did. Reads are also only recorded during render — after commit the proxy tree is frozen and later reads (in effects, callbacks, or timeouts) pass through untracked, so they never establish a dependency. Have the child call `useBloc` itself, or pass plain values (`item.label`) rather than the proxy, when you want the child to own its own dependencies. ## The `select` escape hatch ### Signature ```ts select?: (state: ExtractState, bloc: InstanceReadonlyState) => unknown[] ``` | Parameter | Type | Required | Description | | --------- | -------------------------- | -------- | --------------------------------------------------------------------- | | `state` | `ExtractState` | yes | The current raw state (not a proxy when `select` is active). | | `bloc` | `InstanceReadonlyState` | yes | The bloc instance; use it to include getters in the dependency array. | **Returns:** `unknown[]` — a tuple compared per-index via `Object.is`. The component re-renders only when an element changes. **Behavior.** Providing a `select` function **opts out of auto-tracking** for that call. Instead, the component subscribes to all changes, runs `select` on each, and re-renders only when the returned array changes per-index (`Object.is` per element). In `select` mode the returned `state` is the raw state object, not a tracking proxy. ```tsx function CountOnly() { const [state] = useBloc(CounterCubit, { select: (state) => [state.count], }); return {state.count}; } ``` The callback receives both the state and the bloc instance, so you can select from getters: ```tsx const [state, cart] = useBloc(CartCubit, { select: (state, bloc) => [bloc.total, state.items.length], }); ``` :::note[Inline selectors are fine] The selector is read on every render and is not part of the subscription, so an inline function that closes over props works. It runs on every state change, so keep it cheap. ::: ### When to reach for `select` - You want re-renders driven by a **computed value** (a getter combining several fields) rather than every source path the getter reads. - Auto-tracking records more paths than you want and you want to pin the dependency set explicitly, the way you would a `useMemo` dependency array. - You want a value that is intentionally _coarser_ or _derived_ (e.g. `[bloc.isEmpty]` re-renders on the empty/non-empty boundary, not on every item added). For the inverse case — a component that should never re-render because it only triggers actions — you don't need any option at all: a component that reads no state property records an empty path set and is never woken. See [Performance: split readers and writers](/react/performance#pattern-split-readers-and-writers). ## Choosing a mode ```text Start with auto-tracking (the default — no option) │ ├─ Component only calls methods, never reads state? │ └─ Read nothing from `state`; it won't re-render. No option needed. │ ├─ Want re-renders driven by a computed/derived value, │ or to pin the dependency set by hand? │ └─ Use `select` │ └─ Otherwise: auto-tracking is correct. ``` | Mode | Re-renders when | Best for | | ----------------------- | ---------------------------------------------- | ------------------------------------------- | | Auto-tracking (default) | A tracked path changes | Almost everything | | `select` (escape hatch) | A selected array element changes (`Object.is`) | Computed/derived values, explicit narrowing | | No reads | Never (empty path set) | Action-only components | ## See also - [useBloc](/react/use-bloc) — the full options reference, including `select` - [Performance](/react/performance) — re-render isolation, measuring, and list patterns - [Mental Model](/guide/mental-model) — why proxy tracking beats selectors and memoization --- # React Getting Started > Install BlaC and connect a React component to a Cubit with useBloc, getting from install to a working counter and todo list. Source: /react/getting-started/ BlaC's mental model is one line: **state lives in a class (a Cubit), and `useBloc` connects a component to it, re-rendering only when the data that component actually reads changes.** No providers to wire up, no reducers, no selectors required. For the full reasoning behind that design, see [Mental Model](/guide/mental-model). This page gets you from install to a working counter and todo list. For the framework-agnostic introduction to Cubits, see [Getting Started](/guide/getting-started); this page focuses on the React binding. ## Installation ```bash pnpm add @blac/core @blac/react ``` ```bash npm install @blac/core @blac/react ``` Requires React 18+ and TypeScript is recommended. ## Your first component Two steps: define a Cubit that holds state and exposes methods to change it, then connect a component to it with `useBloc`. The snippet below is complete and copy-pasteable. ```tsx import { Cubit } from '@blac/core'; import { useBloc } from '@blac/react'; class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); // initial state goes to super() } // Arrow-function fields keep `this` bound, so you can pass them straight // to onClick without wrapping. (See "common mistakes" below.) increment = () => this.emit({ count: this.state.count + 1 }); decrement = () => this.update((s) => ({ count: s.count - 1 })); } function Counter() { const [state, counter] = useBloc(CounterCubit); return (

    Count: {state.count}

    ); } ``` `useBloc` returns a tuple `[state, bloc]`: - **state** — the current state, tracked for re-renders. Reading `state.count` here subscribes this component to `count` only; if the state grows other fields, changing them won't re-render `Counter`. - **bloc** — the Cubit instance. Call its methods to drive state changes (`counter.increment()`). There is no `` to add at the root and nothing to register. The first component to call `useBloc(CounterCubit)` creates the instance; the last one to unmount disposes it. ### Three ways to update state A Cubit exposes three mutation methods. Most code uses `emit`; reach for the others when they read better: | Method | Shape | Use when | | -------- | ----------------------------- | ------------------------------------------------------- | | `emit` | `emit(nextState)` | You're replacing the whole state object. | | `update` | `update((prev) => nextState)` | The next state derives from the previous one. | | `patch` | `patch(partial)` | You want to deep-merge a few fields and leave the rest. | :::caution[Common mistakes] - **Regular methods lose `this`.** `increment() { ... }` (a normal method) loses its `this` binding when passed as `onClick={counter.increment}`. Use arrow-function class fields (`increment = () => { ... }`) as every example here does — they capture `this` at construction. - **`emit` replaces, it does not merge.** `emit({ count: 1 })` on a state of `{ count, name }` drops `name`. To change only some fields, use `patch({ count: 1 })` (deep-merge) or spread inside `update((s) => ({ ...s, count: 1 }))`. :::
    Where does the state live? The instance is held in a global registry and reference-counted by `useBloc`. The "what just happened" details — acquire on mount, release on unmount, automatic disposal at zero refs — are covered in [Instance Management](/core/instance-management), and the reasoning behind the design is in [Mental Model](/guide/mental-model).
    ## Tracking modes at a glance Auto-tracking is on by default and needs no configuration. The only knob is per call: pass a `select` function to `useBloc` to choose exactly what drives re-renders instead. | Mode | How to enable | Re-renders when | | ------------- | -------------------------- | ------------------------------------------------ | | Auto-tracking | Default (no `select`) | A state path you read during render changes | | Manual select | `select: (s) => [s.count]` | A selected value changes (per-index `Object.is`) | :::tip[There is no `autoTrack` flag and no required config] Tracking is automatic and structural — driven by a render-time proxy, not by decorating fields or flipping a switch. Use `select` only when a computed value or explicit dependency list should drive re-renders. See [Dependency Tracking](/react/dependency-tracking) for the full model. ::: ## Instance modes at a glance By default all components calling `useBloc(SameCubit)` share one instance. You can scope instances by `args` values or per mount: | Mode | How to enable | Behavior | | ----------------------- | ---------------------------------------------------------- | ------------------------------------------------------- | | Shared | Default (no `args`) | All components share one instance | | Per-args (default hash) | `{ args: { id } }` | Each distinct `args` value gets its own instance | | Per-args (custom key) | `{ args: { id } }` + `static key = (a) => a.id` | Only the keyed field forks instances; others ride along | | Per-mount | `{ args: { _id: useId() } }` + `static key = (a) => a._id` | Each component mount gets a private instance | See [Passing Inputs](/guide/inputs) for the full identity model and precedence. ## Scoping to a registry By default every `useBloc` resolves against one module-global registry, which is what makes instances shared with no providers. `RegistryProvider` overrides that for a subtree: ```tsx import { StateContainerRegistry } from '@blac/core'; import { RegistryProvider } from '@blac/react'; ; ``` Descendant `useBloc` calls acquire from that registry instead of the global one, so the subtree shares nothing with the rest of the page. Use it for test isolation, per-request isolation on the server, and micro-frontends embedded in a host app. See [SSR & per-request isolation](/integrations/ssr). The plain `@blac/core` helpers (`acquire`, `ensure`, `borrow`, `watch`, …) can't see React context, so they always use the global registry (`getRegistry()`), even inside a `RegistryProvider`. To work with the provider's registry from a component, get it with `useRegistry()` and call its methods: ```tsx import { useRegistry } from '@blac/react'; function CountersDebug() { const registry = useRegistry(); return
    {JSON.stringify(registry.getStats())}
    ; } ``` ## What's next? Once the counter works, the natural next steps are reading state efficiently and giving blocs input: - [useBloc](/react/use-bloc) — Full hook reference, options, and identity rules - [Dependency Tracking](/react/dependency-tracking) — How smart re-renders work and their limits - [Passing Inputs](/guide/inputs) — `args`, per-mount instances, and identity keying ## See also - [Mental Model](/guide/mental-model) — Why BlaC works the way it does - [Cubit](/core/cubit) — The state container: `emit`, `update`, `patch`, lifecycle - [Performance](/react/performance) — Patterns and anti-patterns - [Getting Started](/guide/getting-started) — Framework-agnostic introduction --- # Performance > BlaC's re-render isolation — each component re-renders only when the state it reads changes — plus patterns for measuring it and rendering large lists. Source: /react/performance/ BlaC's performance story is **re-render isolation**: each component re-renders only when the specific state it reads changes, and components that read nothing don't re-render at all. This falls out of [auto-tracking](/react/dependency-tracking) — most apps get it for free. This page is for when you want to confirm it's working, push it further, or render large lists efficiently. :::note[Where the gains come from] There is no shared subscription that wakes every consumer. Each `useBloc` call records its own path set and the container wakes only the consumers whose paths intersect a change. So the cost of a state update scales with _how many components read the changed field_, not with _how many components use the container_. The deep "why" lives in [Mental Model](/guide/mental-model). ::: ## How auto-tracking helps By default, `useBloc` wraps the returned state in a Proxy that records which properties your component reads. Only changes to those properties trigger re-renders. ```tsx function UserName() { const [state] = useBloc(UserCubit); return {state.name}; // changes to state.email, state.avatar, etc. are ignored } ``` This happens automatically — no selectors, no memoization, no configuration. For the exact recording rules (and the patterns that quietly over-track), see [Dependency Tracking](/react/dependency-tracking). ## Interactive before/after The two rows below use the same `DashCubit` (three independent fields: temperature, humidity, pressure). The render counters show which components actually re-render on each button press. **Top row — coarse read.** Each card uses `select: all three fields`. Any field change wakes every card — all three counters tick. **Bottom row — per-field auto-tracking.** Each card calls `useBloc` with no `select` and reads only its own field. Auto-tracking records exactly which path was read, so only the card whose field changed re-renders — only that counter ticks. :::tip[What changed between "before" and "after"] The only structural difference is the tracking mode: the coarse row passes a `select` that returns all three fields; the fine row omits `select` and reads only one field per component. No `React.memo`, no `useMemo` — just not over-reading state. ::: ## Measuring re-render isolation Before optimizing, confirm where the re-renders actually are. Three approaches, cheapest first: **1. Inline render counter (quick, local).** ```tsx function MyComponent() { const renderCount = useRef(0); renderCount.current++; const [state] = useBloc(MyCubit); return (
    Renders: {renderCount.current} {/* ... */}
    ); } ``` :::tip[Count in the render body, not in an effect] Increment the ref in the render body. The body runs on every render; `useEffect` runs _after_ commit and can be skipped or batched, so a ref bumped there under-counts. Reading it in the body gives the true render count. ::: **2. React DevTools Profiler (visual, whole-tree).** Record an interaction and look for components that highlight (re-rendered) when they shouldn't have. A component that lights up on a state change it doesn't read is over-tracking — usually a spread or a whole-object read (see [Common mistakes](#common-mistakes)). **3. BlaC DevTools (state-change-centric).** The [BlaC DevTools](/plugins/devtools) show which instances are live and when each state change fires, so you can correlate a render spike with the emit that caused it and spot unexpected instance churn. The [Logging Plugin](/plugins/logging) additionally warns on rapid create/destroy lifecycles in the console. ## Pattern: Split readers and writers Separate components that _display_ state from components that only _trigger_ actions. A component that reads no state property records an empty path set and is therefore never woken by state changes — no option required. ```tsx function Counter() { return ( <> ); } function CountDisplay() { const [state] = useBloc(CounterCubit); return {state.count}; } function CountButtons() { // Destructures only the bloc instance — never touches `state`. const [, counter] = useBloc(CounterCubit); return ( <> ); } ``` `CountButtons` never re-renders on count changes because it reads nothing from `state`. ## Pattern: `select` for coarse, derived control When you want re-renders driven by a **computed value** rather than the raw fields auto-tracking would pick up, reach for `select`. It opts out of auto-tracking and re-renders only when the returned array changes per-index. ```ts select?: (state: S, bloc: T) => unknown[] ``` | Parameter | Type | Required | Description | | --------- | ---- | -------- | ------------------------------------------------------------------ | | `state` | `S` | yes | Current raw state (not a proxy in `select` mode). | | `bloc` | `T` | yes | The bloc instance; use to include getters in the dependency array. | **Returns:** `unknown[]` — compared per-index via `Object.is`. The component re-renders only when an element changes. **Behavior.** Disables auto-tracking for that `useBloc` call. The selector runs on every state change and the component re-renders only when the returned array changes. ```tsx function CartBadge() { const [, cart] = useBloc(CartCubit, { select: (_, bloc) => [bloc.isEmpty], }); return cart.isEmpty ? null : ; } ``` This re-renders only when `isEmpty` flips, not on every item added. See [Dependency Tracking: the `select` escape hatch](/react/dependency-tracking#the-select-escape-hatch). :::tip[Auto-track vs `select`: when to narrow vs derive] Reach for `select` to depend on a **derived/computed** value or to pin an explicit dependency set. To make a component _not_ re-render, don't pass an option — just don't read state. There is no `autoTrack` flag; the mode is chosen by the presence or absence of `select`. ::: ## Pattern: Getters as computed properties Define getters on your Cubit for derived values. A getter centralizes the computation and keeps components thin. ```ts class CartCubit extends Cubit<{ items: CartItem[] }> { get total() { return this.state.items.reduce((sum, i) => sum + i.price, 0); } } ``` :::tip[Getters auto-track in render] The `bloc` returned by `useBloc` is also proxied for getters. When a render reads `cart.total`, the getter runs with a tracked `this.state`, so its source paths join the component's interest set automatically: ```tsx function CartTotal() { const [, cart] = useBloc(CartCubit); return ${cart.total}; } ``` Use `select` when you want the getter's return value to be the boundary instead of the source paths. For example, this wakes only when `total` changes value: ```tsx function CartTotalSelected() { const [, cart] = useBloc(CartCubit, { select: (_, bloc) => [bloc.total], }); return ${cart.total}; } ``` Auto-tracking wakes when one of the getter's tracked source paths changes; `select` wakes when the selected getter result changes by `Object.is`. Either is preferable to reading the raw array and reducing it inside the component. ::: ## List-rendering patterns Iteration **coarsens**: `.map`/`.find`/`for..of` record the array's entry path (`items`) but not per-index paths, and their callbacks receive raw values. So a component that maps over `state.items` re-renders whenever the array changes — including when a single item's field changes (which produces a new array via immutable update). For long lists where individual rows update independently, isolate each row in its own component that reads only its own item. There are two idiomatic shapes: **Map to keys, render rows by id.** The list reads the ids (changes when items are added/removed/reordered); each row reads its own item. ```tsx function TodoList() { const [state] = useBloc(TodoCubit); return (
      {state.items.map((item) => ( ))}
    ); } function TodoRow({ id }: { id: string }) { // `args` keys identity; each row instance reads only its own item. const [item] = useBloc(TodoItemCubit, { args: { id } }); return
  • {item.text}
  • ; } ``` Here each row's `TodoItemCubit` is keyed by `args: { id }`, so toggling one row wakes only that row. See [Passing Inputs](/guide/inputs) for the identity model behind `args`. **Or pass the item down and let the parent own the data.** When a single Cubit holds the list, render rows from a `select` that pins the row's own slice, so a row re-renders only when _its_ item changes: ```tsx function TodoRow({ id }: { id: string }) { const [, todos] = useBloc(TodoCubit, { select: (state) => [state.items.find((i) => i.id === id)], }); const item = todos.state.items.find((i) => i.id === id)!; return
  • {item.text}
  • ; } ``` :::caution[Stable keys, stable order] Use a stable `key` (an id, never the array index) so React reconciles rows correctly across reorders, and prefer immutable updates that replace only the changed item so unaffected rows keep reference-equal data. ::: ## Pattern: Keep most state flat Auto-tracking works at any depth, and `patch` accepts a `DeepPartial` so deep updates are ergonomic — depth is **supported**. But flatter state is still usually the better default: - Each level of nesting is one more proxy to create on read and one more path segment to diff. - Leaf isolation only helps if siblings live at the same level; over-nesting groups unrelated fields under a shared parent, so a whole-object read of that parent over-tracks. ```ts // Prefer this interface UserState { name: string; email: string; avatarUrl: string; } // Over this interface UserState { profile: { personal: { name: string; contact: { email: string } }; media: { avatarUrl: string }; }; } ``` :::note[Nesting isn't banned] Reach for nesting when the structure is _meaningful_ — a list of records, a normalized entity map, a genuinely tree-shaped domain. The guidance is "don't nest for the sake of organizing flat fields," not "never nest." Deep `patch` exists precisely so that legitimately nested state stays easy to update. ::: ## Common mistakes These all manifest the same way in the Profiler: a component re-renders on a change it doesn't display. :::caution[Over-tracking anti-patterns] - **Spreading the whole state** tracks every property. ```tsx // Bad: tracks every property of state return ; // Better: pass only what's rendered return ; ``` - **Reading the whole array to compute a boolean** over-tracks the array's contents. Track the narrow thing you actually need (`state.items.length`), or select the derived value so you only wake on the boundary: ```tsx // Tracks the full array on every change: return 0} />; // Wakes only when empty/non-empty flips (getter via select): const [, cart] = useBloc(CartCubit, { select: (_, b) => [b.isEmpty] }); return ; ``` Reading `!cart.isEmpty` without `select` also tracks correctly, but it wakes on the getter's source paths. Use `select` when only the empty/non-empty boundary should re-render. See [Getters as computed properties](#pattern-getters-as-computed-properties). - **Reading a whole object when you need one field** wakes on any sibling change. Read the leaf (`state.user.name`), not the object (`state.user`). - **Destructuring then drilling off the raw value** — `const { user } = state; user.name` tracks `user`, not `user.name`. Drill through the proxy: `state.user.name`. ::: ## Pattern: Lifecycle hooks instead of useEffect Use `onMount` and `onUnmount` to run side effects tied to the component lifecycle without writing `useEffect`: ```tsx function Feed() { const [state] = useBloc(FeedCubit, { onMount: (feed) => feed.load('latest'), onUnmount: (feed) => feed.cancelPending(), }); if (state.status === 'loading') return ; return ; } ``` This keeps the component body clean and avoids the usual `useEffect` dependency-array pitfalls. `onMount` fires after the bloc is acquired; `onUnmount` fires _before_ the registry releases its ref, so the bloc is still alive when it runs. ## See also - [Dependency Tracking](/react/dependency-tracking) — the recording rules that drive all of the above - [useBloc](/react/use-bloc) — the full options reference (`select`, `args`, `onMount`/`onUnmount`) - [DevTools](/plugins/devtools) — inspect live instances and state-change timing ## Troubleshooting For the full FAQ see [Troubleshooting](/guide/troubleshooting). Below are the performance-specific problems. ### Getter wakes more often than expected **Symptom:** A component reads `bloc.total` (a getter) and re-renders whenever its source paths change, even when the computed total is the same. **Cause:** Auto-tracking records the getter's underlying `this.state` reads. That is usually what you want, but it means the source paths, not the computed return value, drive wakeups. **Fix:** Depend on the getter explicitly with `select` when the computed value should gate re-renders: ```tsx function CartTotal() { const [, cart] = useBloc(CartCubit, { select: (_, bloc) => [bloc.total], }); return ${cart.total}; } ``` Auto-tracking is still correct for the default case: `const [, cart] = useBloc(CartCubit); return {cart.total};`. See [Getters as computed properties](#pattern-getters-as-computed-properties) above. --- # useBloc > The useBloc hook connects a React component to a state container, returning the current state, the bloc instance, and a ref. Source: /react/use-bloc/ The `useBloc` hook connects a React component to a state container. It acquires (or creates) a bloc instance, subscribes to state changes, and returns the current state, the bloc instance, and an internal ref. ## Signature ```ts function useBloc( BlocClass: T, options?: UseBlocOptions, ): [ state: ExtractState, bloc: InstanceReadonlyState, ref: RefObject, ]; ``` | Parameter | Type | Required | Description | | ----------- | ------------------------------------- | -------- | ------------------------------------------------------ | | `BlocClass` | `T extends StateContainerConstructor` | yes | The state-container class to acquire. | | `options` | `UseBlocOptions` | no | Optional configuration: see [Options](#options) below. | **Returns:** a `[state, bloc, ref]` tuple. | Index | Name | Description | | ----- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 0 | `state` | Current state. In auto-tracking mode (the default) this is a tracking proxy that records which paths you read so re-renders stay scoped to them. In `select` mode it's the raw state object. | | 1 | `bloc` | A per-consumer proxy for the Cubit instance. Call its methods to drive changes (`bloc.increment()`). Getters read during render auto-track because `this.state` inside the getter is routed through the current state proxy. | | 2 | `ref` | An advanced-use ref object for component-bloc binding. You almost never need it; destructure just the first two values. | Typically you destructure just the first two: ```tsx const [state, counter] = useBloc(CounterCubit); ``` ## Tracking modes By default, `useBloc` uses auto-tracking to keep re-renders scoped: the hook wraps `state` in a tracking proxy that records which paths you actually read, and the component re-renders only when one of those paths changes. This means two components consuming the same state can read different slices — one re-renders when `left` changes, the other only when `right` changes. **Minimal counter — wired through `useBloc`:** ## Options `useBloc` accepts one optional options object. `UseBlocOptions` has exactly four keys — reach for the one that matches your need: | Option | Type | Required | Description | | ----------- | --------------------------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------- | | `args` | the bloc's `Args` type | when `Args != void` | Typed construction data; derives instance identity. Required when declared, forbidden when `void`. | | `select` | `(state: S, bloc: InstanceReadonlyState) => unknown[]` | no | Explicit dependency selector; disables auto-tracking. | | `onMount` | `(bloc: InstanceType) => void` | no | Called once when the component mounts with the bloc instance. | | `onUnmount` | `(bloc: InstanceType) => void` | no | Called when the component unmounts (bloc still alive at this point). | :::tip[These four are the entire option surface] `UseBlocOptions` has exactly these keys. Two things people expect that are **not** options here: - **Per-mount instances** — pass a per-mount unique value in `args` (e.g. `args: { _id: useId() }`) with a matching `static key` on the class so each mount gets its own private instance (see [`args`](#args) and [Passing Inputs](/guide/inputs)). - **Non-serializable handles** (refs, callbacks) are a bloc-level `Deps` concept, not a `useBloc` option; see [Injecting handles (`deps`)](#injecting-handles-deps). There is no `autoTrack` flag — auto-tracking is the default and you opt out with `select`. ::: ### `args` ```ts args: ExtractArgs; ``` **Required when the bloc declares `Args != void`; forbidden (type `never`) when `void`.** **Returns:** `void` (the option feeds the bloc's `init(args)` before the first snapshot). **Behavior.** Pass typed construction data to the bloc. Args are forwarded to the bloc's `init(args)` method before the first state snapshot, and they derive the instance identity by default — different args ⇒ different instance. Only serializable values are permitted; non-serializable handles (refs, callbacks, DOM elements) must go in the bloc's `Deps` instead. ```tsx class UserCardCubit extends Cubit { protected init(args: { userId: string }) { this.userId = args.userId; } // Side effects belong in onActivate, not init — see Lifecycle below. protected onActivate(signal: AbortSignal) { void this.loadUser(this.userId); } } // args is required and type-checked; omitting it is a compile error const [state] = useBloc(UserCardCubit, { args: { userId } }); ``` - **Identity:** different `args` values produce different instances. By default identity is the structural hash of all args. Override with `static key` on the class. - **Serializable only** — refs, callbacks, and DOM elements must not go in `args` (they'd produce a new instance every render). Put them in the bloc's `Deps` instead — see [Injecting handles (`deps`)](#injecting-handles-deps). - **Per-component private instances** — to give each mount its own instance (disposed on unmount), include a per-mount unique value in `args` and declare `static key` on the class so it becomes the identity key: ```tsx class FormCubit extends Cubit { static key = (a: { _id: string }) => a._id; } // each mount of this component gets its own FormCubit instance const _id = useId(); const [state, cubit] = useBloc(FormCubit, { args: { ...options, _id } }); ``` See [Passing Inputs](/guide/inputs) for the full identity model. ### `select` ```ts select?: (state: ExtractState, bloc: InstanceReadonlyState) => unknown[] ``` **Returns:** `void` (the hook uses the returned array to decide whether to re-render). **Behavior.** Provide an explicit dependency array. The component re-renders only when the shallow-compared values change (per-index `Object.is`). Setting this disables auto-tracking for that call. ```tsx const [state] = useBloc(UserCubit, { select: (state) => [state.name, state.email], }); ``` The function receives both state and the bloc instance, so you can use getters as the explicit re-render boundary: ```tsx const [state, cart] = useBloc(CartCubit, { select: (state, bloc) => [bloc.total, state.items.length], }); ``` :::note[Inline selectors are fine] The selector is read on every render and is not part of the subscription, so an inline function that closes over props works. It runs on every state change, so keep it cheap. ::: ### `onMount` ```ts onMount?: (bloc: InstanceType) => void ``` **Returns:** `void`. **Behavior.** Called once in a mount effect after the bloc is acquired. Use it to kick off work tied to this component's lifecycle (e.g. fetch on mount). ```tsx const [state] = useBloc(DataCubit, { onMount: (bloc) => bloc.fetchData(), }); ``` ### `onUnmount` ```ts onUnmount?: (bloc: InstanceType) => void ``` **Returns:** `void`. **Behavior.** Called when the component unmounts, _before_ the registry releases its ref — so the bloc is still alive when this runs. Use it to clean up subscriptions or cancel pending work. ```tsx const [state] = useBloc(StreamCubit, { onUnmount: (bloc) => bloc.disconnect(), }); ``` ## Injecting handles (`deps`) Non-serializable handles (refs, stable callbacks, controller instances) are **not** a `useBloc` option. They are a bloc-level concept: the bloc declares a `Deps` type and reads from `this.deps.x` (which may be `undefined` — always guard). ```ts class FileUploadCubit extends Cubit< UploadState, { endpoint: string }, { inputRef?: RefObject; onComplete?: () => void } > { async upload() { this.deps.inputRef?.current?.click?.(); // ... perform upload ... this.deps.onComplete?.(); } } ``` For handles that need to trigger initialization on arrival (e.g. a canvas ref), implement `onDepsChanged` on the bloc — see [Cubit](/core/cubit). For how `deps` are supplied and merged across consumers, and the full input model, see [Passing Inputs](/guide/inputs). :::tip[Avoid raw inline callbacks as handles] An inline callback (`onComplete={() => …}`) gets a new identity every render, so a bloc that captured it holds a stale closure. Prefer: 1. **Callback inversion (best):** expose state; let the component call its fresh callback from its own `useEffect`. 2. **Stabilize with `useCallback`** before passing. 3. **Push via an event** — call a bloc method from an effect. ::: ## Identity and keying This is the canonical instance-identity precedence for `useBloc`. Other pages defer to this list: 1. **`` context args** — inherited from a parent provider when present 2. **`static key(args)`** — class-supplied key derived from `args` 3. **Structural hash of `args`** — default when the bloc declares `Args` and no `key` is set 4. **`'default'`** — singleton fallback when the bloc has no `args`, no key, and no provider Blocs declare explicit identity via a static property: ```ts class DocumentCubit extends Cubit< DocState, { docId: string; readonly: boolean } > { static key = (args: DocumentCubit['args']) => args.docId; // docId keys the instance; readonly is config that rides along but doesn't fork instances } ``` A note on ``: it supplies `args` to descendant `useBloc` calls that don't pass their own, so a subtree can share a scoped instance without threading data through props. ```tsx import { BlocProvider } from '@blac/react'; {' '} {/* useBloc(CustomerCubit) here inherits args from the provider */} ; ``` See [Passing Inputs](/guide/inputs) for the full decision matrix. :::caution[Common mistakes] - **Putting non-serializable values in `args`.** Refs, callbacks, and DOM nodes change identity every render, so a fresh `args` object produces a brand-new instance each time (and `args` must be JSON-serializable). Use the bloc's `Deps` for handles — see [Injecting handles (`deps`)](#injecting-handles-deps). - **Expecting an `autoTrack`, `autoInstance`, or `instanceId` option.** None of these exist. Opt out of tracking with `select`; get per-mount instances by including a per-mount unique value in `args` with a matching `static key`. ::: ## Lifecycle 1. **Mount:** `acquire(BlocClass)` creates or retrieves the instance, incrementing the ref count 2. **`init(args)` called** (once, when the instance is first created) before the first state snapshot 3. **`onActivate(signal)` fires** on the instance's 0→1 ownership transition — i.e. the first time anything acquires it. Fired in a layout effect. 4. **Subscribe:** the hook subscribes to the bloc's channel using the selected tracking mode (auto-track or `select`) 5. **`onMount(bloc)` fires** in a mount effect, after the bloc is acquired 6. **Re-render:** only triggered when a tracked state path or a `select` value changes 7. **Unmount:** `onUnmount(bloc)` fires (bloc still alive), then `release(BlocClass)` decrements the ref count. At zero, `onDeactivate()` fires (aborting the `onActivate` signal) and the instance is disposed unless the class is [`keepAlive`](/core/configuration) `init` remains the place to seed synchronous state from `args` — it is not deprecated. `onActivate`/`onDeactivate` are for side effects (fetch, subscribe, timers) tied to whether anything currently owns the instance; see [Cubit](/core/cubit#onactivate--ondeactivate) for the full model. :::caution[Ordering with `useBlocDeps`] `onActivate` fires in a layout effect, before the first `useBlocDeps` slice is applied (which happens in a passive effect). A bloc reading `this.deps.x` inside `onActivate` sees `undefined` on first activation — this matches today's `init()` timing, so it isn't a regression, but `onActivate` is the wrong hook for deps-driven work. Use `onDepsChanged` instead. ::: For the registry mechanics behind acquire/release and ref counting, see [Instance Management](/core/instance-management). ## How re-renders are scheduled `useBloc` re-renders when a tracked path (or the `select` value) changes, and not otherwise. Reads are consistent within a single render pass. ## See also - [Passing Inputs](/guide/inputs) — `args`, `deps`, per-mount isolation, and the identity model - [Dependency Tracking](/react/dependency-tracking) — How auto-tracking decides what re-renders - [Performance](/react/performance) — Splitting readers and writers, anti-patterns - [Cubit](/core/cubit) — The state container these options connect to ## Troubleshooting For the full FAQ see [Troubleshooting](/guide/troubleshooting). Below are the problems most specific to `useBloc`. ### Component re-renders too often **Symptom:** A component re-renders on state changes it doesn't display. **Cause:** Reading a coarse path (whole object or array) instead of the specific leaf you render. A spread (`{...state}`) or an early destructure before drilling into a path widens the tracked set. **Fix:** Read exactly the leaf you render through the live proxy, or use `select` to depend on a derived value: ```tsx // Tracks every property — any change re-renders: const { user } = state; return {user.name}; // Tracks only user.name: return {state.user.name}; // Or: select a derived/computed value so re-renders are gated on the boundary ``` See [Dependency Tracking](/react/dependency-tracking) for the full recording rules. ### State leaks between component mounts **Symptom:** A component mounts, unmounts, and then remounts with stale state from the previous mount — or two independent mounts share state unexpectedly. **Cause:** Both mounts resolve to the same instance key (`'default'` when there are no `args`). When the first mount releases, the instance disposes and the remount creates a fresh one — but if `keepAlive` is set, the instance persists and the remount picks it back up. **Fix:** For one private instance per mount, include a per-mount unique value in `args` and use `static key` to make it the identity key. `useId()` returns a stable-per-mount value: ```tsx class WidgetCubit extends Cubit { static key = (a: { _id: string }) => a._id; } const _id = useId(); const [state] = useBloc(WidgetCubit, { args: { _id } }); ``` See [Instance Management](/core/instance-management) and [Passing Inputs](/guide/inputs). ### `autoTrack`, `autoInstance`, `isolated`, or `instanceId` option not found **Symptom:** TypeScript errors or runtime `undefined` when passing `autoTrack`, `autoInstance`, `isolated`, `instanceId`, or `dependencies` to `useBloc`. **Cause:** None of these are `useBloc` options. `UseBlocOptions` has exactly four keys: `args`, `select`, `onMount`, `onUnmount`. **Fix:** | Tried | What to use instead | | --------------------------- | ------------------------------------------------------------------------------------ | | `autoTrack` | Auto-tracking is the default; opt out with `select` | | `autoInstance` / `isolated` | Include a per-mount unique value in `args` with `static key` for per-mount instances | | `instanceId` | Use `args`-derived identity; include a unique value in `args` + `static key` | | `dependencies` | Use `select` | --- # DevTools > A full DevTools suite — in-app overlay, Chrome DevTools panel, and console API — for inspecting live instances, state diffs, event timelines, and time-travel. Source: /plugins/devtools/ BlaC ships with a full DevTools suite: an in-app overlay, a Chrome DevTools panel, and a console API. Together they let you inspect live instances, view state diffs, browse event timelines, and time-travel to previous states. DevTools is delivered as a [plugin](/plugins/overview): once installed, it observes every state container from the outside, so there is nothing to wire up per bloc. Keep it scoped to `environment: 'development'` — it tracks state snapshots and event history, which you do not want in a production bundle. ## Packages | Package | What it does | | ------------------------ | ------------------------------------------------------------ | | `@blac/devtools-connect` | Core plugin that tracks instances and exposes the global API | | `@blac/devtools-ui` | React UI components (floating overlay and panel) | | BlaC Chrome Extension | Chrome DevTools panel that connects automatically | ## Setup ### 1. Install the plugin ```bash pnpm add @blac/devtools-connect ``` ```ts import { getPluginManager } from '@blac/core/plugins'; import { createDevToolsBrowserPlugin } from '@blac/devtools-connect'; getPluginManager().install(createDevToolsBrowserPlugin(), { environment: 'development', }); ``` This is the minimum setup. The plugin starts tracking all state containers and exposes `window.__BLAC_DEVTOOLS__` for programmatic access. ### 2. Add the in-app UI (recommended) ```bash pnpm add @blac/devtools-ui ``` ```tsx import { BlacDevtoolsUi } from '@blac/devtools-ui'; function App() { return ( <> ); } ``` Drop `` anywhere in your tree. It renders a draggable floating overlay that you toggle with **Alt+D** (or by dispatching a `blac-devtools-toggle` custom event). ### 3. Chrome extension (optional) Install the BlaC Chrome Extension from the Chrome Web Store (or build from `apps/devtools-extension/`). Once installed, a **BlaC** tab appears in Chrome DevTools alongside Elements, Console, etc. The extension connects automatically when the browser plugin is active — no extra configuration needed. It stays in sync across page reloads. ## Plugin configuration ```ts createDevToolsBrowserPlugin({ enabled: true, // kill switch (default: true) maxInstances: 2000, // max tracked instances before FIFO eviction maxSnapshots: 20, // state snapshots kept per instance }); ``` ## What you can do ### Inspect instances The Instances tab lists every active state container with everything you need to identify and triage it at a glance: - **Class name + instance ID** with a state preview. - **`args`** that keyed the instance — surfaced inline so two instances of the same class with different `args` are easy to tell apart. - **Ref holders** (`R:n`) — every active holder of the instance: each `useBloc` mount (which acquires a ref) plus any manual [`acquire()`](/core/instance-management). - **Hydration badge** — `HYDRATING` while state is being restored by the persistence plugin, or `ERR` if hydration failed. - **Insight pills** — inline warnings when a threshold trips: large state size (≥ 50 KB) or a high update rate (≥ 30 updates / 10 s). Click an instance to see its full state tree and a side-by-side diff history. :::tip[Screenshot pending] The Instances tab shows every active bloc. Each row displays the class name, a truncated instance ID, the current ref count (`R:2`), and a compact JSON preview of the current state. A yellow insight pill appears on a bloc when its update rate exceeds 30/10 s. _A human will capture this from the running examples app and drop the file at `apps/docs/public/devtools-instances.png` (then re-add the ``)._ ::: :::note[Reading `R:n`] `R:n` comes straight from the registry's [ref-counting model](/core/instance-management). Every `useBloc` mount acquires a ref, and every manual `acquire()` adds one; the bloc stays alive while the count is above zero. If an instance lingers after all its components have unmounted, a stray `acquire()` without a matching `release()` (or `keepAlive: true`) is the usual cause. ::: ### View state diffs When you select an instance, the detail panel shows a side-by-side diff of the previous and current state. Each state change is recorded with a timestamp and the call stack that triggered it. :::tip[Screenshot pending] The detail panel shows a side-by-side diff for the selected bloc. The left column (red) is the previous state; the right column (green) is the current state. Changed keys are highlighted, with a timestamp and abbreviated call stack below each diff entry. _Drop the file at `apps/docs/public/devtools-state-diff.png` (then re-add the ``)._ ::: ### Browse the event log The Logs tab shows a timeline of all lifecycle events: | Event | When | | ------------------- | --------------------------------------------------------------------------------- | | `instance-created` | A state container is created | | `instance-updated` | State changes (coalesced per animation frame — one entry per rAF, not per emit) | | `instance-disposed` | A state container is disposed | | `refs-changed` | The set of ref holders changes (a `useBloc` mount/unmount or `acquire`/`release`) | | `deps-changed` | The merged `deps` view changes — payload carries `previousDeps` and `currentDeps` | :::note[`instance-updated` batching] State changes are coalesced once per animation frame: if a bloc emits several times before the next rAF tick, the Logs tab records a single `instance-updated` entry with the final state. This keeps the timeline readable during high-frequency updates. The full snapshot history in the detail panel still captures every intermediate state. ::: :::tip[Screenshot pending] The Logs tab lists events with a type badge, a timestamp, and a collapsible payload. The `refs-changed` event shows the previous and current ref count; `instance-updated` shows the coalesced final state for that frame. _Drop the file at `apps/docs/public/devtools-event-log.png` (then re-add the ``)._ ::: ### Time-travel Click any snapshot in the state history to restore the instance to that point. This calls `emit` on the instance with the stored state, so your components update in real time. :::caution[Time-travel bypasses your action logic] Because time-travel calls `emit` directly with a stored snapshot, it skips the methods that normally produce that state. Anything those methods also do — network requests, writes to other blocs, external side effects — does **not** replay. The bloc's in-memory state jumps, but derived or external systems can desync. Treat time-travel as a UI-debugging aid, not a way to re-run application logic. ::: ### Search and filter Use the search bar to filter instances by class name. Useful when you have dozens of active containers. ## Keyboard shortcut Press **Alt+D** to toggle the in-app DevTools overlay. ## Excluding instances from DevTools High-frequency or internal state containers can be hidden: ```ts twoslash import { blac, Cubit } from '@blac/core'; @blac({ excludeFromDevTools: true }) class AnimationCubit extends Cubit<{ frame: number }> { constructor() { super({ frame: 0 }); } } ``` The DevTools plugin skips these instances entirely — no tracking overhead. `excludeFromDevTools` is one of the [`blac()` configuration options](/core/configuration); this page shows it in use, but [Configuration](/core/configuration) documents the full option set. ## Console API With the plugin installed, `window.__BLAC_DEVTOOLS__` is available in the browser console: ```ts // List all active instances __BLAC_DEVTOOLS__.getInstances(); // Full state dump with snapshot history __BLAC_DEVTOOLS__.getFullState(); // Event timeline __BLAC_DEVTOOLS__.getEventHistory(); // Subscribe to real-time events const unsub = __BLAC_DEVTOOLS__.subscribe((event) => { console.log(event.type, event.data); }); // Time-travel an instance to a previous state __BLAC_DEVTOOLS__.timeTravel(instanceId, previousState); // Check plugin version __BLAC_DEVTOOLS__.getVersion(); ``` ## Programmatic access You can also interact with the plugin instance directly in your code: ```ts const devtools = createDevToolsBrowserPlugin(); getPluginManager().install(devtools, { environment: 'development' }); devtools.subscribe((event) => { // { type, timestamp, data } }); devtools.getInstances(); devtools.getFullState(); devtools.getEventHistory(); ``` ## See also - [Plugin Overview](/plugins/overview) — the plugin catalog and the install API - [Logging Plugin](/plugins/logging) — passive console/CI logging; complements DevTools - [Instance Management](/core/instance-management) — the ref-counting model behind `R:n` - [Configuration](/core/configuration) — `excludeFromDevTools` and the other `blac()` options --- # Logging Plugin > Console output for state changes, instance lifecycle events, and monitoring alerts — a passive, scannable record of what your blocs are doing. Source: /plugins/logging/ The logging plugin provides console output for state changes, instance lifecycle events, and monitoring alerts. Reach for it when you want a passive, scannable record in the console or in CI logs — a running narrative of what your blocs are doing. For interactive, point-and-click inspection (state trees, diffs, time-travel) use [DevTools](/plugins/devtools) instead; the two are complementary and can run side by side. Like all plugins, it observes every instance from outside — see [Plugin Overview](/plugins/overview) for the bigger picture. ## Installation ```bash pnpm add @blac/logging-plugin ``` ## Quick setup ```ts import { LoggingPlugin } from '@blac/logging-plugin'; import { getPluginManager } from '@blac/core/plugins'; getPluginManager().install(new LoggingPlugin({ level: 'info' }), { environment: 'development', }); ``` ## What the output looks like The default `format: 'grouped'` writes collapsible `console.group` entries for every event. Here is representative output for a short session — plugin install, a `CartCubit` state change, and its disposal: ```console ▶ [BlaC] Plugin installed Registered types: 1 Total instances: 0 ▶ [BlaC] Created CartCubit#a1b2c3d4 Class: CartCubit Instance ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890 Initial state: { items: [], total: 0 } ▶ [BlaC] CartCubit#a1b2c3d4 state changed Previous: { items: [], total: 0 } Current: { items: [{ id: 1, name: "Widget", qty: 2 }], total: 19.98 } ▶ [BlaC] Disposed CartCubit#a1b2c3d4 Lifespan: 4.2s State changes: 1 Final state: { items: [{ id: 1, name: "Widget", qty: 2 }], total: 19.98 } ``` Groups are collapsed by default — click the `▶` arrow to expand. Switch to `format: 'simple'` for a flat, line-per-event view: ```console [BlaC] Plugin installed (1 types, 0 instances) [BlaC] Created CartCubit#a1b2c3d4 [BlaC] CartCubit#a1b2c3d4 state: {"items":[],"total":0} → {"items":[{"id":1,"name":"W... [BlaC] Disposed CartCubit#a1b2c3d4 (lived 4.2s) ``` The simple format truncates state JSON at 50 characters, which keeps CI logs scannable. :::tip[Screenshot pending] The Console panel shows collapsed `[BlaC]` groups — plugin install, instance creation, a state change, and disposal — with the state-change group expanded to reveal the Previous / Current diff. _Drop the file at `apps/docs/public/devtools-logging-console.png` (then re-add the ``)._ ::: ## Configuration Pass a `LoggingPluginConfig` object to the constructor: ```ts new LoggingPlugin({ level: 'debug', format: 'grouped', include: ['CartCubit', 'AuthCubit'], logStateChanges: true, logLifecycle: true, }); ``` ### Options | Option | Type | Default | Description | | ------------------ | --------------------------------------------- | ----------- | ----------------------------------------------- | | `level` | `'minimal' \| 'info' \| 'debug' \| 'verbose'` | `'info'` | Log verbosity | | `format` | `'simple' \| 'grouped'` | `'grouped'` | Output format. `'grouped'` uses `console.group` | | `logger` | `Logger` | `console` | Custom logger implementation | | `prefix` | `string` | `'[BlaC]'` | Prefix for log messages | | `logLifecycle` | `boolean` | `true` | Log instance creation and disposal | | `logStateChanges` | `boolean` | `true` | Log state changes | | `includeCallstack` | `boolean` | `false` | Show call stacks for state changes | | `logPaths` | `boolean` | `false` | Log the dirtytalk paths that changed | | `include` | `string[]` | — | Whitelist: only log these class names | | `exclude` | `string[]` | — | Blacklist: skip these class names | | `filter` | `FilterFn` | — | Custom filter function | ### Monitoring options | Option | Type | Default | Description | | ------------------------------- | --------- | ------- | ----------------------------------------- | | `instanceCountWarningThreshold` | `number` | `50` | Warn when instance count exceeds this | | `detectRapidLifecycles` | `boolean` | `true` | Detect rapid create/dispose cycles | | `rapidLifecycleWindowMs` | `number` | `1000` | Time window for rapid lifecycle detection | | `rapidLifecycleThreshold` | `number` | `5` | Cycles in window to trigger warning | ## Log levels | Level | Lifecycle | State changes | Monitoring | | --------- | --------- | -------------- | ---------- | | `minimal` | No | No | Yes | | `info` | Yes | Yes | Yes | | `debug` | Yes | Yes (detailed) | Yes | | `verbose` | Yes | Yes (full) | Yes | ## Filtering ### By class name ```ts new LoggingPlugin({ include: ['CartCubit', 'AuthCubit'], // only these exclude: ['TimerCubit'], // or skip these }); ``` ### Custom filter ```ts new LoggingPlugin({ filter: (ctx) => { // ctx: { instance, className, instanceId } return ctx.className !== 'InternalCubit'; // skip specific types }, }); ``` :::note[`filter` ctx is not the plugin `PluginContext`] The `filter` callback receives a small `{ instance, className, instanceId }` object — not the `PluginContext` passed to plugin hooks like `onStateChange`. Don't expect the hydration or query helpers here; this is just enough to decide whether a given instance should be logged. ::: ## Custom logger Replace `console` with your own logging implementation: ```ts new LoggingPlugin({ logger: { log: (...args) => myLogger.info(...args), warn: (...args) => myLogger.warn(...args), error: (...args) => myLogger.error(...args), group: (label) => myLogger.group(label), groupEnd: () => myLogger.groupEnd(), }, }); ``` ## Registry stats Call `logStats()` to print a summary of the current registry state: ```ts const logging = new LoggingPlugin({ level: 'info' }); getPluginManager().install(logging); // later, in a debug context: logging.logStats(); ``` ## Rate limiting State change logging is automatically disabled if more than 1,000 changes per second are detected. This prevents flooding the console in high-frequency scenarios. A warning is logged when rate limiting kicks in. :::tip[Logging went quiet?] If state-change logs suddenly stop, you have likely tripped the 1,000/s limiter — usually a sign of an emit storm (e.g. emitting on every animation frame). That same high update rate is what the `instanceCountWarningThreshold` and `detectRapidLifecycles` monitors are designed to surface. Treat these warnings as a pointer toward a [performance](/react/performance) problem rather than a logging quirk; lifecycle and monitoring logs keep flowing regardless of the limiter. ::: ## See also - [Plugin Overview](/plugins/overview) — the plugin catalog and the install API - [DevTools](/plugins/devtools) — interactive inspection; pairs well with logging - [Performance](/react/performance) — diagnosing the emit storms the rate limiter guards against - [Plugin Authoring](/core/plugins) — the hooks this plugin is built on --- # Plugins > A plugin observes all bloc instances from the outside — the right tool for cross-cutting concerns like logging, inspection, and persistence. Source: /plugins/overview/ A plugin is a single object that taps into the registry's lifecycle — every instance created, every state change, every disposal flows through it. Where [system events](/core/system-events) let one bloc react to its _own_ lifecycle from the inside, a plugin observes _all_ instances from the outside. That makes plugins the right tool for cross-cutting concerns: logging, inspection, persistence — anything you want to apply uniformly without editing each bloc. BlaC has an official set of plugins for these common needs. All plugins implement the `BlacPlugin` interface and are installed once, at startup, via `getPluginManager().install()`. ## Official plugins | Package | Purpose | Typical environment | | ------------------------------------------------------------------------ | --------------------------------------------------------------- | ------------------- | | [`@blac/logging-plugin`](/plugins/logging) | Console logging, instance monitoring, rapid lifecycle detection | Development | | [`@blac/devtools-connect`](/plugins/devtools) | Core plugin for instance tracking and state inspection | Development | | [`@blac/devtools-ui`](/plugins/devtools#2-add-the-in-app-ui-recommended) | In-app draggable overlay and panel UI components | Development | | [`@blac/plugin-persist`](/plugins/persistence) | Persist state to IndexedDB with automatic hydration | All | ## Installing a plugin ```ts import { getPluginManager } from '@blac/core/plugins'; getPluginManager().install(myPlugin, { enabled: true, environment: 'development', // 'development' | 'production' | 'test' | 'all' }); ``` The `environment` option controls when the plugin is active. The check is a **runtime comparison** against `process.env.NODE_ENV` (which falls back to `'development'`) performed inside `install()`: if the environments don't match, the plugin is skipped with a log message and never wired up. So `environment: 'development'` means the plugin does nothing in a production build — but the import still ships unless your bundler tree-shakes it. To keep dev-only plugin code out of the bundle entirely, guard the `install()` call behind your own `import.meta.env.DEV` (or equivalent) check. :::note[Install once, at startup] Install plugins before any blocs are acquired so the first `onCreated` hooks fire. `install()` throws if a plugin with the same `name` is already installed; calling it twice is a mistake, not an upsert. ::: ## Multiple plugins Install as many plugins as you need. They all receive the same lifecycle callbacks independently. ```ts import { getPluginManager } from '@blac/core/plugins'; import { LoggingPlugin } from '@blac/logging-plugin'; import { createDevToolsBrowserPlugin } from '@blac/devtools-connect'; import { createIndexedDbPersistPlugin } from '@blac/plugin-persist'; const pm = getPluginManager(); pm.install(new LoggingPlugin({ level: 'info' }), { environment: 'development', }); pm.install(createDevToolsBrowserPlugin(), { environment: 'development' }); pm.install(createIndexedDbPersistPlugin()); ``` ## Building your own See [Plugin Authoring](/core/plugins) for the full `BlacPlugin` interface, the hook firing model, and the `PluginContext` API. ## See also - [Plugin Authoring](/core/plugins) — the `BlacPlugin` interface and how to write your own - [System Events](/core/system-events) — per-instance lifecycle hooks, and when to use them instead of a plugin - [Logging Plugin](/plugins/logging) · [DevTools](/plugins/devtools) · [Persistence](/plugins/persistence) — the official plugins in detail --- # Persistence Plugin > Automatically saves state to IndexedDB and restores it when instances are created, driven by the registry lifecycle and the hydration API. Source: /plugins/persistence/ The persistence plugin automatically saves state to IndexedDB and restores it when instances are created. It is a [plugin](/plugins/overview) like any other — installed once at startup — but with one extra step: you tell it _which_ classes to persist by calling `.persist()`. Everything else (saving on change, hydrating on create, status tracking) happens through the registry lifecycle, driving the same [hydration API](/core/system-events#hydrationchanged) that any plugin can use. ## Installation ```bash pnpm add @blac/plugin-persist ``` ## Quick setup ```ts import { createIndexedDbPersistPlugin } from '@blac/plugin-persist'; import { getPluginManager } from '@blac/core/plugins'; const persist = createIndexedDbPersistPlugin(); persist.persist(UserSettingsCubit); getPluginManager().install(persist); ``` That's it. `UserSettingsCubit` state is now saved to IndexedDB on every change and restored when the instance is created. ## Plugin options ```ts createIndexedDbPersistPlugin({ databaseName: 'my-app', // default: 'blac-persist' storeName: 'app-state', // default: 'blac-state' pluginName: 'my-persist', // default: 'indexeddb-persist' adapter: customAdapter, // custom storage adapter }); ``` ## Registering containers Call `.persist()` for each class you want to persist. The method is chainable. ```ts persist .persist(UserSettingsCubit) .persist(CartCubit, { debounceMs: 500 }) .persist(ThemeCubit); ``` ### Registration options ```ts persist.persist(CartCubit, { key: 'cart', // custom storage key (default: ClassName:instanceId) debounceMs: 500, // debounce saves (default: 0) stateToDb: (state) => state.items, // transform before saving dbToState: (payload) => ({ items: payload }), // transform on load onHydrated: (state, ctx) => { // called after restore console.log('Cart restored with', state.items.length, 'items'); }, onError: (error, ctx) => { // called on save or load error console.error('Persist error:', error); }, }); ``` | Option | Type | Default | Description | | ------------ | --------------------------- | ---------------------- | -------------------------------------- | | `key` | `string \| (ctx) => string` | `ClassName:instanceId` | Storage key | | `debounceMs` | `number` | `0` | Debounce save operations | | `stateToDb` | `(state, ctx) => TPayload` | Identity | Transform state before saving | | `dbToState` | `(payload, ctx) => S` | Identity | Transform persisted data back to state | | `onHydrated` | `(state, ctx) => void` | — | Called after successful hydration | | `onError` | `(error, ctx) => void` | — | Called on error | ### Dynamic keys Use a function for per-instance storage keys: ```ts persist.persist(EditorCubit, { key: (ctx) => `editor:${ctx.instanceId}`, }); ``` ## Hydration lifecycle When a persisted instance is created: ```text idle → hydrating → hydrated → error (if load fails) ``` After hydration, on state changes: ```text hydrated → saving → saved → error (if save fails) ``` ### Dirty state during hydration If state changes before hydration completes (e.g., the user interacts with the component immediately), the persisted state is **discarded**. The user's changes take priority — stale data from disk should never clobber something the user just did. You can detect this via the `changedWhileHydrating` field on the [`hydrationChanged` system event](/core/system-events#hydrationchanged). That event is the other side of this same mechanism: the persistence plugin reads the flag to decide whether to apply the loaded state, and you can observe the same flag to react in your own code. ## Status monitoring ### Per-instance status ```ts const status = persist.getStatus(myInstance); // { key, className, instanceId, phase, hydratedFromStorage, savedAt?, error? } ``` Phases: `'idle'` | `'hydrating'` | `'hydrated'` | `'saving'` | `'saved'` | `'error'` ### Subscribe to status changes ```ts const unsub = persist.subscribe((event) => { // event: { instance, status } console.log(event.status.phase); }); ``` ## Clearing stored data ```ts await persist.clearRecord('cart'); // clear one record await persist.clearAll(); // clear everything ``` ## Custom storage adapter Implement the `IndexedDbPersistAdapter` interface to use a different storage backend: ```ts import { createIndexedDbPersistPlugin, type IndexedDbPersistAdapter, type PersistedRecord, } from '@blac/plugin-persist'; // A simple in-memory backend (handy for tests or non-IndexedDB environments). const store = new Map(); const memoryAdapter: IndexedDbPersistAdapter = { isAvailable: () => true, get: async (key) => store.get(key) ?? null, put: async (record) => { store.set(record.id, record); }, delete: async (key) => { store.delete(key); }, clear: async () => { store.clear(); }, }; const persist = createIndexedDbPersistPlugin({ adapter: memoryAdapter }); ``` This is useful for testing or for environments where IndexedDB is not available. ## Waiting for hydration `$blac.hydration.wait()` is available on every state container, not something this plugin adds — it resolves once hydration settles (or immediately, if the bloc was never set up to hydrate). That makes it safe to call even on blocs you have not registered with `.persist()`. Use it in your Cubit to defer work until restored state is in place: ```ts class AuthCubit extends Cubit { constructor() { super({ user: null, token: null }); } async initialize() { await this.$blac.hydration.wait(); if (this.state.token) { await this.refreshSession(); } } } ``` The plugin automatically checks IndexedDB availability on install and disables itself with a warning if unavailable. ## Common mistakes :::caution[Watch for these] - **Persisting non-serializable state.** Functions, class instances, `Map`/`Set`, and DOM nodes do not round-trip through IndexedDB. Keep persisted state to plain JSON-compatible data, or use `stateToDb`/`dbToState` to project to and from a serializable shape. - **Expecting persistence without `.persist()`.** Installing the plugin alone does nothing — a class is only saved and hydrated after you register it. Register before any instance of that class is acquired so hydration runs on first create. - **Key collisions across instances.** The default key is `ClassName:instanceId`, which keeps multiple instances of the same class separate. If you set a static `key` string, every instance of that class shares one record and overwrites the others. For per-instance persistence, use a [dynamic key](#dynamic-keys) function instead. ::: ## See also - [Plugin Overview](/plugins/overview) — the plugin catalog and the install API - [System Events](/core/system-events#hydrationchanged) — the `hydrationChanged` event behind the dirty-state logic - [Cubit](/core/cubit#public-properties) — `$blac.hydration.status`, `$blac.hydration.isHydrated`, and `$blac.hydration.wait()` on the container --- # Plugin Recipes > Copy-paste BlacPlugin recipes for common cross-cutting concerns — localStorage, debounced saves, cross-tab sync, Sentry breadcrumbs, and audit logging. Source: /plugins/recipes/ Copy-paste plugins for common cross-cutting concerns. Each recipe is a self-contained `BlacPlugin` that you can drop into your app and tweak. All recipes use only `@blac/core` imports so they work without any extra dependencies unless noted. For the plugin authoring API that every recipe is built on, see [Plugin Authoring](/core/plugins). For the official first-party plugins see [Plugin Overview](/plugins/overview). ## Compose order and throw semantics Plugins execute in install order. Keep these rules in mind when you compose several recipes together: - **Persistence first.** If you layer a logging plugin on top of a persistence plugin, the logger will see the post-hydration state. That is usually what you want — logging will then record the initial _restored_ value rather than the constructor default. - **Sinks last.** Send-to-server plugins (Sentry, audit log) should be installed after any transforming or filtering plugins so they see the final view of the state. - **Hook throws are not caught.** If `onStateChange` throws, the exception propagates synchronously out of the flush and the remaining plugins in the chain are skipped. Guard any I/O inside a `try/catch` in your hooks rather than letting errors bubble. --- ## 1. localStorage adapter A minimal persistence plugin backed by `localStorage` instead of IndexedDB. Good for small string-serializable state where IndexedDB is unnecessary. :::caution[PII caveat] `localStorage` is readable by any script on the same origin. Do not persist state that contains passwords, tokens, health data, or any other personally identifiable information. If you need to store sensitive data, prefer the official [Persistence plugin](/plugins/persistence) with an encrypted adapter, or keep it in a `sessionStorage`-backed variant. ::: ```ts twoslash import { getPluginManager } from '@blac/core/plugins'; import { type BlacPlugin, type PathSet, ALL_PATHS, type StateContainerConstructor, } from '@blac/core'; // --- Recipe: localStorage persistence --- interface LocalStorageOptions { /** * Classes to persist. State must be JSON-serializable. * e.g. [CartCubit, ThemeCubit] */ targets: StateContainerConstructor[]; /** Storage key prefix. Defaults to "blac". */ prefix?: string; } function createLocalStoragePlugin( opts: LocalStorageOptions, ): BlacPlugin { const prefix = opts.prefix ?? 'blac'; const targetNames = new Set(opts.targets.map((T) => T.name)); return { name: 'local-storage-persist', version: '1.0.0', onCreated(ctx) { const c = ctx.container; if (!c || !targetNames.has(c.$blac.name)) return; const key = `${prefix}:${c.$blac.name}:${c.$blac.id}`; try { const raw = localStorage.getItem(key); if (raw !== null) { const saved = JSON.parse(raw) as S; ctx.startHydration(c); ctx.applyHydratedState(c, saved); ctx.finishHydration(c); } } catch { ctx.failHydration(c, new Error('localStorage read failed')); } }, onStateChange(ctx, _prev, next, _paths: PathSet) { const c = ctx.container; if (!c || !targetNames.has(c.$blac.name)) return; const key = `${prefix}:${c.$blac.name}:${c.$blac.id}`; try { localStorage.setItem(key, JSON.stringify(next)); } catch { // Storage full or private-browsing restriction — fail silently. } }, }; } // Install once at app startup. // getPluginManager().install(createLocalStoragePlugin({ targets: [] })); ``` --- ## 2. Debounced-save adapter Wraps any synchronous write with a debounce so rapid state changes only trigger one write per quiet window. Useful when state changes on every keystroke and the save target (IndexedDB, a remote API) is slow. ```ts twoslash import { getPluginManager } from '@blac/core/plugins'; import { type BlacPlugin, type PathSet, ALL_PATHS } from '@blac/core'; // --- Recipe: debounced-save wrapper --- type SaveFn = (key: string, state: unknown) => void; interface DebouncedSaveOptions { /** * Synchronous or async function that does the actual write. * Called once per quiet window per instance. */ save: SaveFn; /** Milliseconds of inactivity before flushing. Default: 300. */ debounceMs?: number; /** Key prefix. Default: "blac". */ prefix?: string; /** Optional set of class names to include. Omit to include all. */ include?: string[]; } function createDebouncedSavePlugin(opts: DebouncedSaveOptions): BlacPlugin { const debounceMs = opts.debounceMs ?? 300; const prefix = opts.prefix ?? 'blac'; const includeSet = opts.include ? new Set(opts.include) : null; const timers = new Map>(); return { name: 'debounced-save', version: '1.0.0', onStateChange(ctx, _prev, next, _paths: PathSet) { const c = ctx.container; if (!c) return; if (includeSet && !includeSet.has(c.$blac.name)) return; const key = `${prefix}:${c.$blac.name}:${c.$blac.id}`; const existing = timers.get(key); if (existing !== undefined) clearTimeout(existing); timers.set( key, setTimeout(() => { timers.delete(key); opts.save(key, next); }, debounceMs), ); }, onDestroyed(ctx) { const c = ctx.container; if (!c) return; const key = `${prefix}:${c.$blac.name}:${c.$blac.id}`; const existing = timers.get(key); if (existing !== undefined) { // Flush immediately on disposal — don't lose the last state. clearTimeout(existing); timers.delete(key); opts.save(key, c.state); } }, }; } // Example: write to localStorage with a 500 ms debounce. const debouncedPlugin = createDebouncedSavePlugin({ save: (key, state) => localStorage.setItem(key, JSON.stringify(state)), debounceMs: 500, include: ['CartCubit', 'DraftCubit'], }); // Install once at app startup. // getPluginManager().install(debouncedPlugin); ``` :::tip[Compose order] Install the debounced-save plugin **after** any persistence plugin if you also use the official `@blac/plugin-persist`. The official plugin handles hydration; this recipe only handles writes. ::: --- ## 3. Cross-tab sync Broadcasts state changes to other browser tabs via `BroadcastChannel` and applies incoming updates through the hydration API. Use this when multiple tabs show the same app and you want them to stay in sync (e.g. a shopping cart or an auth session). :::caution[PII caveat] `BroadcastChannel` messages are visible to all same-origin tabs for the duration of the browser session. Strip or redact any sensitive fields (tokens, PII) in the `serialize` option before broadcasting. ::: ```ts twoslash import { getPluginManager } from '@blac/core/plugins'; import { type BlacPlugin, type PathSet, ALL_PATHS, type StateContainerConstructor, } from '@blac/core'; // --- Recipe: cross-tab sync via BroadcastChannel --- interface CrossTabSyncOptions { /** Classes to sync across tabs. */ targets: StateContainerConstructor[]; /** BroadcastChannel name. Default: "blac-sync". */ channelName?: string; /** * Optional transform applied before broadcasting. * Use this to strip PII or tokens before the message leaves the tab. */ serialize?: (state: S) => unknown; /** Optional transform applied when receiving a message. */ deserialize?: (payload: unknown) => S; } interface SyncMessage { type: 'state-update'; className: string; instanceId: string; state: unknown; } function createCrossTabSyncPlugin( opts: CrossTabSyncOptions, ): BlacPlugin { const channelName = opts.channelName ?? 'blac-sync'; const targetNames = new Set(opts.targets.map((T) => T.name)); let channel: BroadcastChannel | null = null; // Track which instance keys are receiving a remote update so we don't // echo the incoming state back out to other tabs. const receiving = new Set(); return { name: 'cross-tab-sync', version: '1.0.0', onInstall(ctx) { if (typeof BroadcastChannel === 'undefined') return; channel = new BroadcastChannel(channelName); channel.onmessage = (event: MessageEvent) => { const msg = event.data; if (msg?.type !== 'state-update') return; if (!targetNames.has(msg.className)) return; const instances = ctx.queryInstances( // Use the metadata helper to match by name since we only have strings. // queryInstances needs the constructor — skip if class not in registry. opts.targets.find( (T) => T.name === msg.className, ) as StateContainerConstructor, ); for (const inst of instances) { if (inst.$blac.id !== msg.instanceId) continue; const key = `${msg.className}:${msg.instanceId}`; receiving.add(key); const next = opts.deserialize ? opts.deserialize(msg.state) : (msg.state as S); ctx.startHydration(inst); ctx.applyHydratedState(inst, next); ctx.finishHydration(inst); // Remove after the microtask so onStateChange fires first. Promise.resolve().then(() => receiving.delete(key)); } }; }, onUninstall() { channel?.close(); channel = null; }, onStateChange(ctx, _prev, next, _paths: PathSet) { const c = ctx.container; if (!c || !channel) return; if (!targetNames.has(c.$blac.name)) return; const key = `${c.$blac.name}:${c.$blac.id}`; if (receiving.has(key)) return; // avoid echo const payload = opts.serialize ? opts.serialize(next as unknown as S) : next; const msg: SyncMessage = { type: 'state-update', className: c.$blac.name, instanceId: c.$blac.id, state: payload, }; channel.postMessage(msg); }, }; } // Install once at app startup. // getPluginManager().install(createCrossTabSyncPlugin({ targets: [] })); ``` --- ## 4. Sentry breadcrumb sink Adds a Sentry breadcrumb for each state change. Because breadcrumbs are sent with every event, this gives you a state-change timeline attached to error reports — similar to Redux DevTools' action history but visible in Sentry. :::caution[PII caveat] State is included verbatim in Sentry breadcrumbs and uploaded to Sentry's servers. Redact or omit any fields that contain passwords, tokens, health data, or other PII using the `sanitize` option below. Failure to do so may violate GDPR, HIPAA, or your own privacy policy. ::: :::note[No `@sentry/browser` in twoslash scope] The Sentry SDK is not installed in the docs type-checker. The snippet below uses a plain `ts` fence. Copy it into a project where `@sentry/browser` (or `@sentry/react`) is installed. ::: ```ts import { getPluginManager } from '@blac/core/plugins'; import type { BlacPlugin, PathSet } from '@blac/core'; import * as Sentry from '@sentry/browser'; // --- Recipe: Sentry breadcrumb sink --- interface SentryPluginOptions { /** Classes to include. Omit to include all blocs. */ include?: string[]; /** Classes to exclude. */ exclude?: string[]; /** * Strip sensitive fields from state before attaching to the breadcrumb. * Called with the full next state; return the shape you want Sentry to see. * REQUIRED if your state contains PII, tokens, or health data. */ sanitize?: (state: unknown, className: string) => unknown; } function createSentryPlugin(opts: SentryPluginOptions = {}): BlacPlugin { const includeSet = opts.include ? new Set(opts.include) : null; const excludeSet = opts.exclude ? new Set(opts.exclude) : null; return { name: 'sentry-breadcrumbs', version: '1.0.0', onStateChange(ctx, _prev, next, _paths: PathSet) { const c = ctx.container; if (!c) return; if (includeSet && !includeSet.has(c.$blac.name)) return; if (excludeSet && excludeSet.has(c.$blac.name)) return; const data = opts.sanitize ? opts.sanitize(next, c.$blac.name) : next; Sentry.addBreadcrumb({ category: 'blac.state', message: `${c.$blac.name} changed`, level: 'info', data: data as Record, }); }, }; } // Install once at app startup. Gate to production so dev overhead stays low. // getPluginManager().install(createSentryPlugin({ // sanitize: (state, name) => { // if (name === 'AuthCubit') { // const s = state as { user: unknown; token: unknown }; // return { user: s.user, token: '[redacted]' }; // } // return state; // }, // }), { environment: 'production' }); ``` --- ## 5. Audit log Writes a structured log entry for every state change, creation, and disposal. Useful for compliance or debugging — you can pipe `entries` to your existing logging infrastructure, a remote endpoint, or a circular buffer. :::caution[PII caveat] The audit log captures the full `prev` and `next` states. If any bloc holds PII (user profiles, payment details, health data), provide a `sanitize` function to strip sensitive fields before they are stored. Do not ship raw state to external endpoints without first sanitizing it. ::: ```ts twoslash import { getPluginManager } from '@blac/core/plugins'; import { type BlacPlugin, type PathSet, ALL_PATHS } from '@blac/core'; // --- Recipe: structured audit log --- type AuditEventKind = 'created' | 'state-changed' | 'disposed'; interface AuditEntry { kind: AuditEventKind; className: string; instanceId: string; timestamp: number; prev?: unknown; next?: unknown; /** Human-readable changed paths, or "(all)" for a full-replace. */ paths?: string[]; } interface AuditLogOptions { /** * Called with each new entry. Wire to console.log, a remote endpoint, * or push into a circular buffer. */ onEntry: (entry: AuditEntry) => void; /** Optional per-entry sanitizer. Strip PII before logging externally. */ sanitize?: (state: unknown, className: string) => unknown; /** Optional allowlist of class names to audit. */ include?: string[]; /** Optional denylist of class names to skip. */ exclude?: string[]; } function createAuditLogPlugin(opts: AuditLogOptions): BlacPlugin { const includeSet = opts.include ? new Set(opts.include) : null; const excludeSet = opts.exclude ? new Set(opts.exclude) : null; const allowed = (name: string) => { if (includeSet && !includeSet.has(name)) return false; if (excludeSet && excludeSet.has(name)) return false; return true; }; const san = (state: unknown, name: string) => opts.sanitize ? opts.sanitize(state, name) : state; return { name: 'audit-log', version: '1.0.0', onCreated(ctx) { const c = ctx.container; if (!c || !allowed(c.$blac.name)) return; opts.onEntry({ kind: 'created', className: c.$blac.name, instanceId: c.$blac.id, timestamp: Date.now(), next: san(c.state, c.$blac.name), }); }, onStateChange(ctx, prev, next, paths: PathSet) { const c = ctx.container; if (!c || !allowed(c.$blac.name)) return; const changedPaths = paths === ALL_PATHS ? ['(all)'] : [...paths].map((id) => c.interner.lookup(id)); opts.onEntry({ kind: 'state-changed', className: c.$blac.name, instanceId: c.$blac.id, timestamp: Date.now(), prev: san(prev, c.$blac.name), next: san(next, c.$blac.name), paths: changedPaths, }); }, onDestroyed(ctx) { const c = ctx.container; if (!c || !allowed(c.$blac.name)) return; opts.onEntry({ kind: 'disposed', className: c.$blac.name, instanceId: c.$blac.id, timestamp: Date.now(), prev: san(c.state, c.$blac.name), }); }, }; } // Example: log to console with a circular buffer of the last 100 entries. const auditEntries: AuditEntry[] = []; getPluginManager().install( createAuditLogPlugin({ onEntry(entry) { if (auditEntries.length >= 100) auditEntries.shift(); auditEntries.push(entry); console.debug('[audit]', entry.kind, entry.className, entry.paths); }, sanitize(state, name) { if (name === 'AuthCubit') { const s = state as { user: unknown; token?: string }; return { ...s, token: '[redacted]' }; } return state; }, }), { environment: 'development' }, ); ``` --- ## See also - [Plugin Authoring](/core/plugins) — the `BlacPlugin` interface and hook reference - [Plugin Overview](/plugins/overview) — first-party plugin catalog - [Persistence](/plugins/persistence) — official IndexedDB persistence, including a custom adapter API - [Logging](/plugins/logging) — official structured console logging plugin --- # Core Testing API > The full @blac/core/testing helper reference — registry isolation, state seeding, stubs, overrides, and async helpers. Source: /testing/core/ All core testing utilities are imported from `@blac/core/testing`. They work with any test runner and have no framework-specific dependencies — only the test globals (`describe`/`it`/`expect`/`vi`) come from your runner, which in this project is [`vite-plus`](/testing/overview#why-registry-isolation-matters). ```ts import { blacTestSetup, createTestRegistry, withTestRegistry, registerOverride, overrideEnsure, createCubitStub, withBlocState, withBlocMethod, flush, } from '@blac/core/testing'; ``` :::tip[Which helper do I reach for?] These tools overlap, so start from the question you are answering: | Goal | Helper | | ------------------------------------------------------------ | ------------------------------------- | | Isolate **every** test in a file (the default) | `blacTestSetup()` | | Isolate **one block** of code, sync or async | `withTestRegistry(fn)` | | Use a **real** instance, just with starting state | `withBlocState(Cubit, state)` | | Replace **one method** on a real instance | `withBlocMethod(Cubit, name, impl)` | | Build an instance with state, mocked methods, args, or deps | `createCubitStub(Cubit, options)` | | Force the registry to return **your** instance (e.g. a stub) | `registerOverride(Cubit, instance)` | | Override the registry for a **single expression** | `overrideEnsure(Cubit, instance, fn)` | Rule of thumb: prefer real instances (`withBlocState`, `withBlocMethod`) so you exercise real logic; reach for stubs and overrides only to control a bloc's _dependencies_ or to intercept side effects. ::: ## Registry isolation ### `blacTestSetup()` Installs `beforeEach` / `afterEach` hooks that swap in a fresh registry for every test. Call it once at the top of a test file or inside a `describe` block. ```ts import { it, expect } from 'vite-plus/test'; import { blacTestSetup } from '@blac/core/testing'; import { ensure } from '@blac/core'; blacTestSetup(); it('test A — fresh registry', () => { const counter = ensure(CounterCubit); counter.increment(); expect(counter.state.count).toBe(1); }); it('test B — does not see test A state', () => { const counter = ensure(CounterCubit); expect(counter.state.count).toBe(0); }); ``` This is the recommended default for almost every test file. Use the lower-level helpers below only when you need more control. ### `createTestRegistry()` Returns a new, empty `StateContainerRegistry`. Use this when you need to create a registry without installing it as the global one. ```ts const registry = createTestRegistry(); ``` ### `withTestRegistry(fn)` ```ts function withTestRegistry(fn: (registry: StateContainerRegistry) => T): T; ``` Runs a callback with a temporary isolated registry, then restores the previous one. Works with both sync and async callbacks. The registry is always restored — even if the callback throws. ```ts import { withTestRegistry } from '@blac/core/testing'; import { ensure } from '@blac/core'; const result = withTestRegistry(() => { const counter = ensure(CounterCubit); counter.increment(); return counter.state.count; }); expect(result).toBe(1); // Outside the callback, the original registry is restored ``` Async example: ```ts await withTestRegistry(async () => { const data = ensure(AsyncDataCubit); await data.fetchItems(); expect(data.state.items).toHaveLength(3); }); ``` `withTestRegistry` can be nested. Each level gets its own isolated registry: ```ts withTestRegistry(() => { ensure(CounterCubit).increment(); // count = 1 withTestRegistry(() => { // Inner registry is independent expect(ensure(CounterCubit).state.count).toBe(0); }); // Outer registry is restored expect(ensure(CounterCubit).state.count).toBe(1); }); ``` ## Seeding state ### `withBlocState(BlocClass, state, args?)` ```ts function withBlocState( BlocClass: T, state: Partial>, args?: ExtractArgs, ): InstanceType; ``` Ensures an instance exists in the registry and seeds its state. Works for any `StateContainer`. For object state, the state is merged via `patch()` so you only need to provide the fields you care about. Returns the instance. ```ts blacTestSetup(); it('filters active todos', () => { const todo = withBlocState(TodoCubit, { items: [ { id: '1', text: 'Buy milk', done: false }, { id: '2', text: 'Walk dog', done: true }, ], }); expect(todo.activeTodos).toHaveLength(1); }); ``` For named instances, pass the `args` that identify them (resolved via the class's `static key`/structural hash): ```ts withBlocState(EditorCubit, { content: 'Hello' }, { docId: 'doc-1' }); withBlocState(EditorCubit, { content: 'World' }, { docId: 'doc-2' }); ``` ### `withBlocMethod(BlocClass, methodName, impl, args?)` ```ts function withBlocMethod( BlocClass: T, methodName: keyof InstanceType, impl: (...args: any[]) => any, args?: ExtractArgs, ): InstanceType; ``` Ensures an instance exists and replaces a single method. Other methods remain fully functional. ```ts import { it, expect, vi } from 'vite-plus/test'; blacTestSetup(); it('calls save on submit', () => { const mockSave = vi.fn(); withBlocMethod(FormCubit, 'save', mockSave); const form = ensure(FormCubit); form.save(); expect(mockSave).toHaveBeenCalled(); }); ``` This is useful when you want a real instance with real state management, but need to intercept a specific side-effecting method (API calls, navigation, etc.). ## Stubs ### `createCubitStub(BlocClass, options?)` ```ts function createCubitStub( BlocClass: T, options?: { state?: Partial>; methods?: Partial>, Function>>; args?: ExtractArgs; deps?: Partial>; }, ): InstanceType; ``` Creates a real instance with optional pre-set state and method overrides. Works for any `StateContainer`, not only `Cubit`. The stub is initialized the same way the registry initializes an instance: `init()` runs and per-class `equality` applies. Subscriptions, `emit`, `patch`, and `dispose` all work normally. Only the explicitly overridden methods are replaced. | Option | Effect | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `state` | Seeds starting state. Merged via `patch()` for object state (provide only the fields you care about); replaced via `emit()` for non-object state. | | `methods` | Replaces specific methods on the instance. Everything else stays real. | | `args` | Passed to `init(args)`. Only allowed when the bloc declares a non-`void` `Args`; omitting it calls `init(undefined)`, as the registry would. | | `deps` | Pre-wires a [`deps`](/guide/inputs) slice so `onDepsChanged` fires during the test. | The options apply in source order: `args` runs `init` first, then `state` overrides on top, then `methods` are swapped, then `deps` are wired (which may itself emit via `onDepsChanged`). ```ts import { it, expect, vi } from 'vite-plus/test'; blacTestSetup(); it('creates a stub with partial state', () => { const stub = createCubitStub(AuthCubit, { state: { loggedIn: true, userId: 'user-1' }, }); expect(stub.state.loggedIn).toBe(true); expect(stub.state.role).toBe('viewer'); // default preserved }); it('creates a stub with mocked methods', () => { const mockLogout = vi.fn(); const stub = createCubitStub(AuthCubit, { methods: { logout: mockLogout }, }); stub.logout(); expect(mockLogout).toHaveBeenCalled(); // login() and other methods still work normally }); ``` #### Stubbing blocs that take `args` or `deps` If a bloc derives its initial state from `args` in `init()`, pass `args`: ```ts it('seeds state from args via init()', () => { // class UserCubit extends Cubit { // protected init(a) { this.emit({ id: a.userId }); } // } const stub = createCubitStub(UserCubit, { args: { userId: 'alice' } }); expect(stub.state.id).toBe('alice'); }); ``` For blocs that react to injected handles, pass `deps` to drive `onDepsChanged`: ```ts it('reacts to a wired dependency', () => { const el = { id: 42 }; const stub = createCubitStub(CanvasCubit, { deps: { el } }); expect(stub.deps.el).toBe(el); // onDepsChanged fired with this slice }); ``` `args` and `deps` can be combined with `state` and `methods` in the same call. :::tip `createCubitStub` is the only place `deps` enter the testing API. The registry helpers (`withBlocState`, `withBlocMethod`, `registerOverride`, `overrideEnsure`) take an optional trailing `args` argument — it both keys the instance and seeds `init(args)` — but none of them accept `deps`. For a deps-wired instance, build it with `createCubitStub({ args, deps })` and inject it with `registerOverride`. See [Inputs](/guide/inputs) for the `args`/`deps` model. ::: Stubs are commonly paired with `registerOverride` to inject them into the registry for use by dependent code or React components. ## Overrides ### `registerOverride(BlocClass, instance, args?)` ```ts function registerOverride( BlocClass: T, instance: InstanceType, args?: ExtractArgs, ): void; ``` Injects a specific instance into the registry, replacing any existing one. The previous instance is disposed if it exists. Use this when you need full control over what instance the registry returns for a given class. ```ts blacTestSetup(); it('uses the override when resolving dependencies', () => { const authStub = createCubitStub(AuthCubit, { state: { loggedIn: true, userId: 'test-user' }, }); registerOverride(AuthCubit, authStub); // DashboardCubit.depend(AuthCubit) will resolve to authStub const dashboard = ensure(DashboardCubit); expect(dashboard.summary).toContain('test-user'); }); ``` ### `overrideEnsure(BlocClass, instance, fn, args?)` ```ts function overrideEnsure( BlocClass: T, instance: InstanceType, fn: () => R, args?: ExtractArgs, ): R; ``` Scoped override — registers the instance inside a temporary registry, runs the callback, then cleans up. Useful for one-off assertions without affecting other tests. ```ts it('computes total with shipping', () => { const shipping = createCubitStub(ShippingCubit, { state: { rate: 9.99 } }); const total = overrideEnsure(ShippingCubit, shipping, () => { const cart = ensure(CartCubit); return cart.total; }); expect(total).toBe(9.99); }); ``` ## Async helpers ### `flush()` ```ts function flush(): Promise; ``` BlaC coalesces state changes onto a microtask (the default `MicrotaskScheduler`), so a value set inside an action is not visible to subscribers, `onSystemEvent('stateChanged')` handlers, or plugin hooks until the channel flushes. `flush()` drains those pending microtasks — `await` it after triggering a change and before asserting on any side effect. ```ts it('propagates async state', async () => { const data = ensure(AsyncDataCubit); data.triggerUpdate(); await flush(); expect(data.state.updated).toBe(true); }); ``` :::note[Reading `state` directly does not need a flush] `flush()` is about _propagation_. The instance's own `state` getter is synchronous — `data.state` is up to date the instant `emit`/`patch` returns. You only need `await flush()` when asserting on something downstream of the channel flush (a `subscribe` listener, a `stateChanged` handler, a re-rendered component, a plugin). ::: :::caution[`flush()` drains microtasks, not timers] `flush()` awaits the microtask queue. If your code schedules work with `setTimeout`/`setInterval`, drive it with `vi.useFakeTimers()` + `vi.advanceTimersByTime()` instead — `flush()` will not advance real or fake timers. This is the mirror image of the once-per-flush batching described in [System Events](/core/system-events). ::: ## Combining helpers The real power of these utilities comes from combining them. Here's a complete example testing a cubit that depends on two others: ```ts import { describe, it, expect, vi } from 'vite-plus/test'; import { blacTestSetup, createCubitStub, registerOverride, withBlocState, } from '@blac/core/testing'; import { ensure } from '@blac/core'; blacTestSetup(); describe('DashboardCubit', () => { it('shows user greeting with cart count', () => { // Seed dependencies with known state registerOverride( AuthCubit, createCubitStub(AuthCubit, { state: { loggedIn: true, userId: 'u1', name: 'Alice' }, }), ); registerOverride( CartCubit, createCubitStub(CartCubit, { state: { items: [{ id: '1', price: 10 }] }, }), ); const dashboard = ensure(DashboardCubit); expect(dashboard.summary).toBe('Alice has 1 items'); }); it('shows guest greeting when logged out', () => { registerOverride( AuthCubit, createCubitStub(AuthCubit, { state: { loggedIn: false }, }), ); const dashboard = ensure(DashboardCubit); expect(dashboard.summary).toContain('Guest'); }); }); ``` ## Testing cubits with `depend()` When a cubit uses `this.depend()`, it resolves dependencies lazily from the registry. Use `registerOverride` to control what it gets: ```ts class CartCubit extends Cubit { private shipping = this.depend(ShippingCubit); get total() { return ( this.state.items.reduce((s, i) => s + i.price, 0) + this.shipping.untracked().state.rate ); } } ``` ```ts blacTestSetup(); it('includes shipping in total', () => { registerOverride( ShippingCubit, createCubitStub(ShippingCubit, { state: { rate: 4.99 } }), ); const cart = withBlocState(CartCubit, { items: [{ id: '1', price: 20 }], }); expect(cart.total).toBe(24.99); }); ``` The stub is a real `ShippingCubit` instance, so `cart`'s `.shipping.untracked()` resolves it exactly as it would in production — no special mocking framework needed. (For the production-side picture of how `depend()` resolves from the registry, see [Bloc Communication](/core/bloc-communication).) :::danger[Common mistakes] - **Overriding a dependency _after_ the dependent bloc has already read it.** `depend()` resolves lazily on each call, but if your dependent bloc cached a value in `init()` from the dependency, register the override _before_ you `ensure` the dependent bloc. - **Forgetting that `depend()` uses `ensure` (no ref).** Within a test the registry is isolated, so this rarely bites — but in app code a depended-on bloc can be disposed if nothing holds a ref. See [Bloc Communication](/core/bloc-communication). ::: ## Testing keyed instances Pass the `args` that identify each instance to any helper (the class's `static key`/structural hash resolves them to the registry key): ```ts blacTestSetup(); it('manages independent editor instances', () => { withBlocState(EditorCubit, { content: 'Doc A' }, { docId: 'doc-a' }); withBlocState(EditorCubit, { content: 'Doc B' }, { docId: 'doc-b' }); const editorA = ensure(EditorCubit, { args: { docId: 'doc-a' } }); const editorB = ensure(EditorCubit, { args: { docId: 'doc-b' } }); expect(editorA.state.content).toBe('Doc A'); expect(editorB.state.content).toBe('Doc B'); }); ``` ## See also - [Testing Overview](/testing/overview) — why registry isolation matters and the import convention - [React Testing](/testing/react) — `renderWithBloc` / `renderWithRegistry`, built on these primitives - [Instance Management](/core/instance-management) — the registry, `ensure`, and ref counting these helpers isolate - [Bloc Communication](/core/bloc-communication) — the canonical `depend()` / cross-bloc example - [System Events](/core/system-events) — the once-per-flush batching that `flush()` drains --- # Testing > First-party testing utilities for fast, isolated tests of your cubits and the components that use them. Source: /testing/overview/ BlaC provides first-party testing utilities so you can write fast, isolated tests for your cubits and the components that use them. The utilities live in two entry points: - **`@blac/core/testing`** — registry isolation, state seeding, stubs, and method mocks - **`@blac/react/testing`** — React component rendering with controlled bloc state Both are tree-shakable and shipped as separate entry points, so they are never included in your production bundle. ## Installation The core testing utilities have no extra dependencies. Install your test runner of choice (Vitest is recommended): ```bash pnpm add -D vitest ``` ```bash npm install -D vitest ``` ```bash yarn add -D vitest ``` For React component tests, you also need `@testing-library/react`: ```bash pnpm add -D @testing-library/react ``` ```bash npm install -D @testing-library/react ``` ```bash yarn add -D @testing-library/react ``` ## Why registry isolation matters BlaC stores every bloc instance in a single global [registry](/core/instance-management). That is what lets any component call `useBloc(CounterCubit)` and reach the _same_ instance without prop drilling. In tests, that same shared-by-default behavior works against you: a bloc created or mutated in one test is still in the registry when the next test runs, so tests leak state into each other and fail depending on execution order. The mental model for testing BlaC is therefore simple: **give every test its own registry.** Once each test starts from an empty registry, the shared-instance model becomes a feature again — you seed exactly the instances a test needs and nothing carries over. Every testing utility in this guide is built around that idea. The most common approach is `blacTestSetup()`, which swaps in a fresh registry before each test and restores the original after: ```ts import { describe, it, expect } from 'vite-plus/test'; import { blacTestSetup } from '@blac/core/testing'; import { ensure } from '@blac/core'; blacTestSetup(); describe('CounterCubit', () => { it('starts at zero', () => { const counter = ensure(CounterCubit); expect(counter.state.count).toBe(0); }); it('does not see state from the previous test', () => { // This test gets a completely fresh registry const counter = ensure(CounterCubit); expect(counter.state.count).toBe(0); }); }); ``` ## Quick start ### Testing a cubit in isolation ```ts import { it, expect } from 'vite-plus/test'; import { blacTestSetup, withBlocState } from '@blac/core/testing'; import { ensure } from '@blac/core'; import { TodoCubit } from '../blocs/TodoCubit'; blacTestSetup(); it('adds an item', () => { const todo = ensure(TodoCubit); todo.addItem('Buy milk'); expect(todo.state.items).toHaveLength(1); }); it('can start with seeded state', () => { withBlocState(TodoCubit, { items: ['Existing item'] }); const todo = ensure(TodoCubit); expect(todo.state.items).toHaveLength(1); }); ``` ### Testing a React component ```tsx import { it, expect } from 'vite-plus/test'; import { screen } from '@testing-library/react'; import { renderWithBloc } from '@blac/react/testing'; import { Counter } from '../components/Counter'; import { CounterCubit } from '../blocs/CounterCubit'; it('displays the current count', () => { renderWithBloc(, { bloc: CounterCubit, state: { count: 42 }, }); expect(screen.getByText('42')).toBeInTheDocument(); }); ``` :::caution[Import test globals from `vite-plus/test`] This project runs tests through `vite-plus`, so `describe`/`it`/`expect`/`vi` are imported from `vite-plus/test`, not from `vitest` directly: ```ts import { describe, it, expect, vi } from 'vite-plus/test'; ``` Tests still run if you import bare globals, but linting fails. If you use a different runner, substitute its import — the BlaC helpers themselves are runner-agnostic. ::: :::danger[Common mistakes] - **Forgetting `blacTestSetup()`** (or another isolation helper). Without it, a bloc mutated in one test survives into the next and you get order-dependent, flaky failures — exactly the problem isolation exists to prevent. See [Core Testing API](/testing/core) for the lower-level alternatives. - **Asserting on async state too early.** State changes are flushed on a microtask, so a value set inside an async action may not be visible on the very next line. `await flush()` (or React Testing Library's `findBy*` queries) before asserting. See [System Events](/core/system-events) for the batching model. ::: ## See also - [Core Testing API](/testing/core) — the full helper reference (`blacTestSetup`, stubs, overrides, seeding) - [React Testing API](/testing/react) — rendering components with controlled bloc state - [Instance Management](/core/instance-management) — the registry these helpers isolate --- # React Testing > Render React components with controlled bloc state in an isolated registry — renderWithBloc and renderWithRegistry, built on the core testing primitives. Source: /testing/react/ The React testing utilities build on top of [`@blac/core/testing`](/testing/core) and `@testing-library/react` to provide a simple way to render components with controlled bloc state. They follow the same principle as the core helpers: render the component into a fresh, isolated [registry](/core/instance-management) so each test starts from a known set of bloc instances. ```ts import { renderWithBloc, renderWithRegistry } from '@blac/react/testing'; ``` :::tip[Which one do I use?] - **One bloc to control?** Reach for `renderWithBloc` — it stubs and registers a single bloc in one call. - **Multiple blocs, or fine-grained setup?** Use `renderWithRegistry` and configure the registry yourself in a callback. Both render into their own registry through a `RegistryProvider` and never change the global registry, so they compose freely with `blacTestSetup()`. ::: :::note[Test globals] As elsewhere in this project, import `describe`/`it`/`expect`/`vi` from [`vite-plus/test`](/testing/overview#why-registry-isolation-matters). The examples below also use `userEvent`, which comes from `@testing-library/user-event`. ::: :::tip `@testing-library/react` is an optional peer dependency of `@blac/react`. Install it alongside your test runner to use these utilities. ::: ## `renderWithBloc(ui, options)` ```ts function renderWithBloc( ui: ReactElement, options: { bloc: T; state?: Partial>; methods?: Partial>, Function>>; args?: ExtractArgs; deps?: Partial>; }, ): RenderResult & { bloc: InstanceType }; ``` Renders a React component with a single bloc pre-configured in an isolated registry. Under the hood it: 1. Creates a fresh test registry 2. Creates a cubit stub with the provided options (`state`, `methods`, `args`, `deps`) 3. Registers it as an override keyed by the resolved `args` value 4. Renders the component via `@testing-library/react`, wrapped in a `RegistryProvider` for that registry The returned object is the standard `RenderResult` from Testing Library, plus a `bloc` property containing the stub instance. Aside from `bloc`, the options are exactly the [`createCubitStub`](/testing/core#createcubitstubblocclass-options) options — including `args` (to run `init()`) and `deps` (to fire `onDepsChanged`). See [Inputs](/guide/inputs) for what those lanes mean. ### Basic usage ```tsx import { it, expect } from 'vite-plus/test'; import { screen } from '@testing-library/react'; import { renderWithBloc } from '@blac/react/testing'; it('displays the count', () => { renderWithBloc(, { bloc: CounterCubit, state: { count: 7 }, }); expect(screen.getByText('7')).toBeInTheDocument(); }); ``` ### Interacting with the bloc The returned `bloc` is the live stub instance. Mutate it to test how your component responds to state changes: ```tsx import { act } from '@testing-library/react'; it('updates when count changes', () => { const { bloc } = renderWithBloc(, { bloc: CounterCubit, state: { count: 0 }, }); act(() => bloc.increment()); expect(screen.getByText('1')).toBeInTheDocument(); }); ``` :::tip[`act()` vs `userEvent`] Wrap **direct bloc mutations** (`bloc.increment()`) in `act()` so React processes the resulting re-render before you assert. For **user-driven interactions** (typing, clicking), prefer `userEvent` — it already wraps its actions in `act()`, so you do not need to add your own. As a rule: reach into the bloc → `act()`; drive the UI → `userEvent`. ::: ### Mocking methods Pass `methods` to replace specific methods on the stub. This is useful for intercepting side effects like API calls or navigation: ```tsx import { it, expect, vi } from 'vite-plus/test'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; it('calls save on form submit', async () => { const mockSave = vi.fn(); renderWithBloc(, { bloc: SettingsCubit, state: { theme: 'dark', locale: 'en' }, methods: { save: mockSave }, }); await userEvent.click(screen.getByRole('button', { name: 'Save' })); expect(mockSave).toHaveBeenCalled(); }); ``` ### Named instances If your component keys a bloc by `args` (e.g. `useBloc(EditorCubit, { args: { docId } })`), pass the same `args` so the stub registers under the matching instance key: ```tsx it('renders the correct editor', () => { renderWithBloc(, { bloc: EditorCubit, state: { content: 'Hello world' }, args: { docId: 'doc-42' }, }); expect(screen.getByText('Hello world')).toBeInTheDocument(); }); ``` :::caution[Match the resolved instance key] The `args` you pass must resolve to the same key your component's `useBloc` resolves to — `renderWithBloc` runs them through the same `static key`/structural-hash resolution. There is no separate string-key option; identity always flows through `args`. See [Inputs](/guide/inputs) for how identity is resolved. ::: ### `args`- and `deps`-based blocs When a component renders a bloc that takes `args` or `deps`, you can pre-build the stub with those lanes so the component sees fully-initialized state. Pass `args` (runs `init()`) and `deps` (fires `onDepsChanged`) right alongside `state`: ```tsx it('renders a profile seeded from args and deps', () => { // function Profile() { // const [s] = useBloc(ProfileCubit, { args: { name: 'Alice' } }); // // deps are wired from a mount effect in app code; the test seeds them below. // ... // } const { bloc } = renderWithBloc(, { bloc: ProfileCubit, args: { name: 'Alice' }, deps: { token: 'secret' }, }); expect(bloc.state.displayName).toBe('Alice'); expect(screen.getByText('Alice')).toBeInTheDocument(); }); ``` Because the stub is seeded before render, the component reads the initialized state on its very first paint — no `flush()` needed for the initial values. ## `renderWithRegistry(ui, setup)` ```ts function renderWithRegistry( ui: ReactElement, setup: (registry: StateContainerRegistry) => void, ): RenderResult; ``` Renders a component with a fresh registry that you configure via a callback. Use this when a component depends on multiple blocs, or when you need more control than `renderWithBloc` provides. ### Multi-bloc components ```tsx import { it, expect } from 'vite-plus/test'; import { screen } from '@testing-library/react'; import { renderWithRegistry } from '@blac/react/testing'; import { createCubitStub, registerOverride } from '@blac/core/testing'; it('shows dashboard with user and cart data', () => { renderWithRegistry(, () => { registerOverride( AuthCubit, createCubitStub(AuthCubit, { state: { loggedIn: true, name: 'Alice' }, }), ); registerOverride( CartCubit, createCubitStub(CartCubit, { state: { items: [{ id: '1', name: 'Widget', price: 9.99 }] }, }), ); }); expect(screen.getByText('Alice')).toBeInTheDocument(); expect(screen.getByText('$9.99')).toBeInTheDocument(); }); ``` ### Mixing with core helpers The setup callback runs with the test registry active, so all `@blac/core/testing` helpers work inside it: ```tsx import { withBlocState, withBlocMethod } from '@blac/core/testing'; it('renders notification list', () => { renderWithRegistry(, () => { withBlocState(AuthCubit, { loggedIn: true, userId: 'u1' }); withBlocState(NotificationCubit, { items: [ { id: '1', message: 'Welcome!', read: false }, { id: '2', message: 'New feature', read: true }, ], }); }); expect(screen.getByText('Welcome!')).toBeInTheDocument(); expect(screen.getAllByRole('listitem')).toHaveLength(2); }); ``` ## Cleanup Neither helper changes the global registry: stubs are set up under a temporary registry (like `withTestRegistry`) and the component reads it from a `RegistryProvider`. Nothing needs restoring when Testing Library's `cleanup()` unmounts the tree, even if setup throws. Core helpers called from the test body (`acquire`, `borrow`, `watch`) use the global registry, not the one the component renders into. Use the returned `bloc`, or the registry passed to the `renderWithRegistry` callback, to reach the test instances. ## Common patterns ### Testing loading states ```tsx it('shows a spinner while loading', () => { renderWithBloc(, { bloc: ArticleCubit, state: { articles: [], status: 'loading', error: null }, }); expect(screen.getByRole('progressbar')).toBeInTheDocument(); }); it('shows articles after load', () => { renderWithBloc(, { bloc: ArticleCubit, state: { articles: [{ id: '1', title: 'Hello' }], status: 'success', error: null, }, }); expect(screen.getByText('Hello')).toBeInTheDocument(); expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); }); it('shows error message on failure', () => { renderWithBloc(, { bloc: ArticleCubit, state: { articles: [], status: 'error', error: 'Network error' }, }); expect(screen.getByText('Network error')).toBeInTheDocument(); }); ``` ### Testing user interactions ```tsx import { it, expect } from 'vite-plus/test'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; it('adds a todo item', async () => { const { bloc } = renderWithBloc(, { bloc: TodoCubit, state: { items: [], filter: 'all' }, }); await userEvent.type(screen.getByRole('textbox'), 'Buy milk'); await userEvent.click(screen.getByRole('button', { name: 'Add' })); expect(bloc.state.items).toContainEqual( expect.objectContaining({ text: 'Buy milk' }), ); }); ``` ### Testing components with `onMount` Components that use `onMount` to trigger data loading can have that method mocked: ```tsx import { it, expect, vi } from 'vite-plus/test'; it('calls fetchData on mount', () => { const mockFetch = vi.fn(); renderWithBloc(, { bloc: DataCubit, state: { items: [], status: 'idle' }, methods: { fetchData: mockFetch }, }); expect(mockFetch).toHaveBeenCalledOnce(); }); ``` ### Testing components that read getters Components using `bloc.total` or similar getters work naturally since the stub is a real instance: ```tsx it('displays computed total', () => { renderWithBloc(, { bloc: CartCubit, state: { items: [ { id: '1', price: 10 }, { id: '2', price: 20 }, ], }, }); expect(screen.getByText('$30')).toBeInTheDocument(); }); ``` The getter runs against real state, so you test real logic — not a mocked return value. :::danger[Common mistakes] - **Mismatched args key.** If the component resolves a named or args-keyed instance but you register the stub under different args (or no args), the component will spin up its own real instance and ignore your stub. Pass the same `args` to `renderWithBloc` that the component passes to `useBloc` so the resolved keys match. - **Asserting before React commits.** Direct bloc mutations need `act()`; user-driven flows need `await userEvent...`; async data needs `await screen.findBy*` (or `await flush()` then a sync query). A missing `await` is the usual cause of "the test sees the old UI." ::: ## See also - [Core Testing API](/testing/core) — the stub/override/seeding primitives these helpers wrap - [Testing Overview](/testing/overview) — registry isolation and the import convention - [useBloc](/react/use-bloc) — the hook your components use, and how it resolves instance identity - [Inputs](/guide/inputs) — the `args` / `deps` lanes you seed in tests --- # Using BlaC outside React > @blac/core has no dependency on React — use blocs in vanilla JS, Node.js, or any framework via watch (observe) and acquire/release (own the lifecycle). Source: /integrations/outside-react/ `@blac/core` has no dependency on React. You can use blocs in vanilla JavaScript, Node.js scripts, worker threads, or any framework — without `@blac/react` installed. This page covers the two main patterns: **observing** state with `watch` and **owning** the lifecycle with `acquire`/`release`. ## Observing state with `watch` `watch` is the primary way to react to state changes outside a component. It creates a subscription, fires the callback once immediately with the current state, and then on every subsequent change. ```ts twoslash import { watch, Cubit } from '@blac/core'; class AuthCubit extends Cubit<{ token: string | null }> { constructor() { super({ token: null }); } setToken = (token: string) => this.update(() => ({ token })); logout = () => this.update(() => ({ token: null })); } // Subscribe — fires immediately, then on every change. const stop = watch(AuthCubit, (auth) => { if (auth.state.token) { console.log('Signed in:', auth.state.token.slice(0, 8) + '...'); } else { console.log('Signed out'); } }); // Tear down when no longer needed. stop(); ``` `watch` returns a `stop` function. Always call it when the subscription is no longer needed. Forgetting is the most common outside-React leak — a subscription you never stop keeps the bloc alive and runs your callback for the life of the process. See [watch](/core/watch) for the full API. ### Watching multiple blocs Pass a `readonly` array to observe several blocs at once. The callback fires when **any** of them changes: ```ts twoslash import { watch, Cubit } from '@blac/core'; class UserCubit extends Cubit<{ name: string }> { constructor() { super({ name: '' }); } } class ThemeCubit extends Cubit<{ mode: 'light' | 'dark' }> { constructor() { super({ mode: 'light' }); } } declare function updateDocTitle(name: string, mode: string): void; const stop = watch([UserCubit, ThemeCubit] as const, ([user, theme]) => { updateDocTitle(user.state.name, theme.state.mode); }); // Call stop() when tearing down stop(); ``` ### Self-terminating watches Return `watch.STOP` from the callback to unsubscribe after a condition is met — useful for one-shot observations: ```ts twoslash import { watch, Cubit } from '@blac/core'; class AppCubit extends Cubit<{ ready: boolean }> { constructor() { super({ ready: false }); } } declare function startWorker(): void; watch(AppCubit, (app) => { if (app.state.ready) { startWorker(); return watch.STOP; // unsubscribes after the first match } }); ``` ## Managing lifecycle with acquire and release In React, `useBloc` owns the `acquire`/`release` pair for you. Outside React you call them directly. Every `acquire` increments the ref count; the matching `release` decrements it. At ref count zero the instance is disposed. ```ts twoslash import { acquire, release, Cubit } from '@blac/core'; class TaskQueueCubit extends Cubit<{ pending: number }> { constructor() { super({ pending: 0 }); } enqueue = () => this.update((s) => ({ pending: s.pending + 1 })); dequeue = () => this.update((s) => ({ pending: Math.max(0, s.pending - 1) })); } // Acquire a ref — the instance is created on first acquire. const queue = acquire(TaskQueueCubit); queue.enqueue(); console.log(queue.state.pending); // 1 // Release when done — drops the ref count. // At ref count 0 the instance is disposed (unless keepAlive). release(TaskQueueCubit); ``` :::danger[Pair every acquire with a release] A missing `release` is an instance leak: the ref count never reaches zero, the bloc never disposes, and any subscriptions and timers it holds run forever. Use a `try/finally` when the release might be skipped due to an error: ```ts twoslash import { acquire, release, Cubit } from '@blac/core'; class ReportCubit extends Cubit<{ data: string[] }> { constructor() { super({ data: [] }); } } declare function processData(data: string[]): Promise; async function runReport() { const report = acquire(ReportCubit); try { await processData(report.state.data); } finally { // Runs even if processData throws. release(ReportCubit); } } ``` ::: ### Read-only access without a ref If you only need to read the current state and do not need to keep the instance alive, use `borrow` (throws if absent) or `borrowSafe` (returns an error object). Neither takes a ref, so no `release` is needed: ```ts twoslash import { borrow, borrowSafe, Cubit } from '@blac/core'; class ConfigCubit extends Cubit<{ apiUrl: string }> { constructor() { super({ apiUrl: '' }); } } // borrow: throws if the instance does not exist. // Use when absence is a programming error. const url = borrow(ConfigCubit).state.apiUrl; // borrowSafe: discriminated union instead of throwing. // Use when absence is expected. const result = borrowSafe(ConfigCubit); if (result.error) { console.warn('Config not initialized'); } else { console.log(result.instance.state.apiUrl); } ``` ### Combining watch with acquire `watch` calls `ensure` internally — it does not take a ref. If you need both a stable subscription **and** a ref (to keep the instance alive while nothing else holds it), `acquire` first, then `watch`: ```ts twoslash import { acquire, release, watch, Cubit } from '@blac/core'; class MetricsCubit extends Cubit<{ fps: number }> { constructor() { super({ fps: 0 }); } } declare function updateDashboard(fps: number): void; // Keep the instance alive + observe changes. const metrics = acquire(MetricsCubit); const stop = watch(MetricsCubit, (m) => { updateDashboard(m.state.fps); }); // On teardown — stop watching first, then release the ref. function teardown() { stop(); release(MetricsCubit); } ``` ## Node.js scripts and worker threads Blocs work in Node.js without any changes. The module-level registry is per-module-graph, so in a single Node process the same singleton rules apply as in a browser tab. For **worker threads** (`node:worker_threads`), each worker has its own module graph and therefore its own registry. Blocs do not cross thread boundaries — pass data between threads via the standard `postMessage` / `MessageChannel` API. For **long-running servers** that handle multiple requests in the same process, apply the [per-request registry isolation](/integrations/ssr) pattern to prevent state bleed between concurrent users. ```ts twoslash import { watch, acquire, release, Cubit } from '@blac/core'; class JobCubit extends Cubit<{ status: 'idle' | 'running' | 'done' }> { constructor() { super({ status: 'idle' }); } start = () => this.update(() => ({ status: 'running' })); finish = () => this.update(() => ({ status: 'done' })); } // Node.js CLI script — no React, no browser. const job = acquire(JobCubit); // Watch for completion then exit. watch(JobCubit, (j) => { if (j.state.status === 'done') { console.log('Job complete'); return watch.STOP; } }); job.start(); // ... do work ... job.finish(); // callback fires, prints "Job complete" release(JobCubit); ``` ## See also - [watch](/core/watch) — full API, `instance()` references, `watch.STOP`, and the coalescing model - [Instance Management](/core/instance-management) — the full registry API: `acquire`, `release`, `borrow`, `ensure`, `clearAll` - [SSR & per-request isolation](/integrations/ssr) — isolating the registry in long-lived server processes - [Testing core logic](/testing/core) — `clearAll` for isolating the registry between tests --- # SSR & per-request isolation > BlaC's registry is a module-level singleton — on a server, give each request its own isolated registry to prevent cross-request state leakage. Source: /integrations/ssr/ BlaC's registry is a module-level singleton. In the browser that is exactly what you want: every `useBloc` call across your app resolves the same `(class, key)` to the same instance, so state is shared with no providers. On a server, that same singleton is a problem — one process handles many requests, and a shared registry means **one user's bloc state can bleed into another user's request**. This page names that hazard plainly and shows the fix that already ships: swapping the active registry per request via `setRegistry` / `getRegistry`, so each request gets its own isolated set of bloc instances. ## The problem: cross-request state leakage On the client there is one user and one registry, for the life of the tab. On the server there is one **process** and one module-level registry (`globalRegistry`), but **many concurrent users**. Every registry function — `acquire`, `ensure`, `borrow`, `release`, and `watch` (which resolves via `ensure`) — routes through a single module-level variable inside `@blac/core`: ```ts // inside @blac/core (simplified) let _registry = globalRegistry; export function getRegistry(): StateContainerRegistry { return _registry; } export function setRegistry(registry: StateContainerRegistry): void { _registry = registry; } ``` If nothing swaps `_registry`, **every request shares `globalRegistry`**. Concretely: - Request A renders for user A, `acquire(SessionCubit)` creates an instance keyed `'default'` and fills it with user A's data. - Request B renders for user B. `acquire(SessionCubit)` finds that _same_ `'default'` instance already on the shelf and reuses it — now serving user A's session data to user B. This is not a re-render bug or a hydration mismatch. It is a correctness and **security** hole: server-rendered HTML for one user can contain another user's state. The same goes for any keyed instance whose key is not perfectly request-unique — and for `keepAlive` blocs, which by design survive ref count zero and therefore persist _across_ requests in a long-lived process. :::danger[The cross-request leak rule] **On the server, never let two requests share a registry.** A module-level singleton that lives for the life of the process is shared by every request that process handles. Give each request its own `StateContainerRegistry`, make it the active one for the duration of that request only, and discard it when the request ends. State that outlives a single request is a data-leak waiting to happen. ::: ## The fix: a registry per request `@blac/core` exposes the registry as a swappable slot, not a hard-coded global. Three exports do the work: ```ts twoslash import { StateContainerRegistry, getRegistry, setRegistry, globalRegistry, } from '@blac/core'; // Construct a fresh, empty registry — its own instance map, no shared state. const requestRegistry = new StateContainerRegistry(); // Make it the active registry. Every acquire/ensure/release/watch now uses it. setRegistry(requestRegistry); // Read the active registry (defaults to globalRegistry until you swap it). const active: StateContainerRegistry = getRegistry(); // Restore the process-wide default when the request is done. setRegistry(globalRegistry); ``` | Export | What it is | Role in SSR | | ------------------------ | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | | `StateContainerRegistry` | The registry **class**. `new StateContainerRegistry()` is an empty registry with its own instance map. | Construct one per request. | | `setRegistry(registry)` | Sets the module-level active registry. | Point the active slot at the request's registry. | | `getRegistry()` | Returns the current active registry (initially `globalRegistry`). | Read it back if you need to operate on the active one directly. | | `globalRegistry` | The default singleton, used until `setRegistry` is called. | The value you restore the slot to after a request. | A fresh `StateContainerRegistry` starts empty: no registered types, no instances, no refs. Bloc classes are not pre-registered against a registry — the first `acquire`/`ensure` of a class against whichever registry is active registers and creates the instance there. So once `setRegistry(requestRegistry)` runs, the very first `acquire(SessionCubit)` in that request creates a brand-new `SessionCubit` **inside `requestRegistry`**, isolated from every other request. :::note[There is no `registry.dispose()`] A `StateContainerRegistry` has no whole-registry teardown method. To dispose the blocs inside one, call `registry.clearAll()` (disposes and removes every instance it holds, ignoring ref counts and `keepAlive`). If you simply drop all references to a per-request registry instead, it — and the blocs it holds — become eligible for garbage collection along with the request. Verify the surface in [Instance Management](/core/instance-management). ::: ## The pattern: scope the render with `RegistryProvider` `setRegistry` is a single global slot, so "set at the start of the handler, restore at the end" only holds if nothing async interleaves between two requests. Under real concurrency — `await`ing a data fetch mid-render while another request runs — a bare set/restore races: request B's `setRegistry` clobbers the slot while request A is suspended. `@blac/react` avoids the slot entirely. `RegistryProvider` scopes every descendant `useBloc` to a registry passed through React context, so concurrent renders cannot interfere: ```tsx import { StateContainerRegistry } from '@blac/core'; import { RegistryProvider } from '@blac/react'; async function handleRequest(req: Request): Promise { const registry = new StateContainerRegistry(); try { const html = renderToString( , ); return new Response(`
    ${html}
    `, { headers: { 'content-type': 'text/html' }, }); } finally { registry.clearAll(); } } ``` Because the registry travels through context rather than a module variable, two requests rendering concurrently in the same process each resolve their own instances — no `AsyncLocalStorage` bridge, no restore step, nothing to race. :::note[Code outside the React tree] `RegistryProvider` covers `useBloc`, including the blocs it resolves through `depend()`. Direct `@blac/core` calls (`acquire`, `ensure`, `watch`) always resolve through the module-level slot, never the provider; inside a component, `useRegistry()` returns the provider's registry. Server code that touches blocs _outside_ the render must either take the registry explicitly or scope the slot with `setRegistry` around a synchronous section. ::: ## Client hydration uses the global registry On the client there is one registry for the whole tab, so isolation is a non-issue — do **not** call `setRegistry` in browser code. Client `useBloc` calls resolve against `globalRegistry` (the default the slot points at until something swaps it), exactly as in a non-SSR app. That means the registries on the two sides are independent: the server's per-request registry produced the HTML string, and the client's `globalRegistry` rehydrates from scratch on mount. BlaC does not serialize the server registry into the client — blocs re-create from their constructors during hydration. If a bloc needs server-computed data to render identically on both sides, pass that data in as [`args`](/guide/inputs) (or serialize it into the page and feed it to the bloc's `init`), the same way you would seed any client-side state for hydration. ## A note on Vite / dev SSR Under Vite's dev SSR (`vite-node`, `ssrLoadModule`) each request goes through the same module graph in one process, so the module-level registry slot is shared exactly as in production — the per-request pattern above applies unchanged in dev. The one extra hazard is **HMR**: hot-reloading the module that owns `globalRegistry`, or your ALS store, can leave you with two copies of the singleton (the old one and the freshly evaluated one) and instances split between them. If you see stale or duplicated instances only after a hot update, force a full reload rather than chasing it as a logic bug. ## See also - [Instance Management](/core/instance-management) — the registry, ref counting, `clearAll`, and the lending-library model - [Passing Inputs](/guide/inputs) — `args` and instance identity, the seam for feeding server data into hydration - [Configuration](/core/configuration) — `keepAlive` (which makes cross-request persistence worse) and the circuit breakers - [watch](/core/watch) — observing blocs outside React, and how it resolves through the active registry - [Testing core logic](/testing/core) — `clearAll` for isolating the registry between tests, the same primitive you use per request --- # DirtyTalk > The framework-agnostic packages under BlaC — after a mutation, what changed, who cares, and when do we tell them? Source: /dirtytalk/ DirtyTalk is the family of packages BlaC is built on. They answer one question: **after a mutation, what changed, who cares, and when do we tell them?** You do not need to install or import these to use BlaC — `@blac/core` wires them up for you. This page exists so the layering is on record. ## The idea A renderer mutates a scene and must repaint. A state container mutates state and must notify subscribers. Both face the same three questions, and the naive answer pushes the work down to every consumer — re-walking the whole state tree once per subscriber, or repainting the whole canvas because a single dirty bit lost track of _where_ the change was. DirtyTalk computes "what changed" **once, at the source**, as a **Region** — a value with `empty`, `union`, and `intersects`. Each subscriber declares an interest region. Waking the right subscribers is then one cheap `intersects` check each, against one shared dirty region: work proportional to the number of subscribers, not to the size of the state times the number of subscribers. ## The packages | Package | Role | A `Region` is | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | | `@dirtytalk/engine` | The abstract algebra and scheduling glue: the `Space` and `Scheduler` interfaces, and `DirtyChannel`. Ships no concrete `Region`. | abstract — you supply one | | `@dirtytalk/spatial` | The 2D-rendering instantiation. `RectSpace`, a `SceneNode` / `SceneRoot` tree, and a `PointerRouter`. | a list of damage rects | | `@dirtytalk/structural` | The state-container instantiation. `PathSetSpace`, a `PathInterner`, the `trackRender` proxy recorder, and diff helpers. | a set of interned path IDs | Each domain package answers the same algebra in its own terms. "Union two dirty regions" means concatenating damage lists in spatial and set-union in structural. The `DirtyChannel` machinery — coalescing, selective fan-out, re-entrancy handling, error isolation — is written once in the engine and reused by both. `@blac/core` is a consumer of `@dirtytalk/structural`: BlaC's per-consumer path tracking is that package's path-set model. BlaC also re-exports [`untracked`](/react/dependency-tracking) from it. All three are standalone and framework-agnostic — usable with no React and no BlaC — but they are documented here only at this level. For BlaC itself, start at [the introduction](/guide/introduction). --- # BlaC > Type-safe state management for React with automatic re-render optimization. Source: / :::caution[Beta] BlaC v2 is in beta. Breaking changes may ship in patch releases, so pin exact versions and check the changelog before upgrading. ::: The whole loop ## Here. This is all of it. A class holds your state and the methods that change it. One hook connects it to React. No store setup, no provider tree, no action types. ```tsx twoslash import React from 'react'; import { Cubit } from '@blac/core'; import { useBloc } from '@blac/react'; class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } increment = () => this.emit({ count: this.state.count + 1 }); } function Counter() { const [state, counter] = useBloc(CounterCubit); return ; } ``` The same code, running live. The badge counts real renders: Why BlaC ## Less wiring, fewer re-renders Read `state.user.name` and the component re-renders only when that value changes. No selectors, no memoization. [How tracking works](/react/dependency-tracking/) Methods are your actions, `this.state` is the source of truth, and `emit`, `update` and `patch` change it. [Core concepts](/guide/concepts/) A getter can read another bloc's state, and components reading it re-render when either bloc changes. [Bloc communication](/core/bloc-communication/) Instances are shared and ref-counted: the first consumer creates one, the last to unmount disposes it. [Instance management](/core/instance-management/) [DevTools](/plugins/devtools/), [persistence](/plugins/persistence/), [logging](/plugins/logging/), [testing utilities](/testing/overview/) and [SSR isolation](/integrations/ssr/) attach without touching your logic. The core is framework-agnostic. `watch()` a bloc from a router, a game loop or a test. [Outside React](/integrations/outside-react/) Where to next