# 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` subscribes to the bloc's path-scoped channel and re-renders through a `useReducer` dispatch — React's ordinary update path. It does **not** use `useSyncExternalStore`, and it never throws a promise to a Suspense boundary or unwraps one via `use()`. There is no code path in `useBloc` that suspends a component. (Verified in [useBloc](/react/use-bloc); the hook reads state directly during render and forces an update on change.) 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, which throws. 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 - [Patterns & Recipes](/guide/patterns) — the request-id recipe in context, plus hydration-aware loading - [Cubit](/core/cubit) — `emit` / `patch` / `update`, getters, and the `init` hook - [useBloc](/react/use-bloc) — `onMount`, `select`, and why there's no `useSyncExternalStore` - [System Events](/core/system-events) — the `dispose` and `hydrationChanged` lifecycle hooks - [Best Practices](/guide/best-practices) — why an explicit `status` union beats scattered booleans --- # Best Practices > The opinionated do/don't principles for shaping state, choosing input lanes, modeling async work, and avoiding the habits that quietly cause bugs. Source: /guide/best-practices/ This page is about judgment, not mechanics. It collects the opinionated do/don't principles that keep a BlaC codebase predictable as it grows: how to shape state, how to choose between the input lanes, how to model async work, and which habits quietly cause bugs. If you want copy-paste recipes for these situations, see [Patterns & Recipes](/guide/patterns) — that page is the concrete how-to. This page is the _why_ behind the choices. :::tip[How to read this page] Each section states a principle, gives a one-line rationale, and shows a good-vs-bad pair. The [Anti-patterns](#anti-patterns) section at the end is a quick-reference of the mistakes, each with its fix. ::: ## Cubit vs Bloc: there is only Cubit **Principle:** reach for `Cubit`. BlaC does not ship a separate `Bloc` class. If you come from `flutter_bloc`, you may expect two base classes — a `Cubit` you call methods on, and an event-driven `Bloc` you dispatch events to. BlaC has one concrete base: [`Cubit`](/core/cubit), which extends the abstract `StateContainer`. You change state by calling methods that call `emit` / `update` / `patch`. There is no `add(event)` dispatch layer. ```ts // Good — a method per intent, called directly class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } increment = () => this.patch({ count: this.state.count + 1 }); reset = () => this.emit({ count: 0 }); } ``` ```ts // Bad — there is no Bloc class to extend, and no event enum to dispatch class CounterBloc extends Bloc { /* does not exist */ } ``` If you genuinely want an event-sourced log (every intent recorded as a value before it is reduced), model it explicitly inside a Cubit — keep an `events: Event[]` array in state and a reducer method — rather than looking for a framework `Bloc`. For the lower-level engine that BlaC is built on, see [DirtyTalk](/dirtytalk/). ## State shape: flat, serializable, and free of derived values **Principle:** state holds the _source of truth_; everything computable from it is a getter, not a stored field. Two rules keep state honest: 1. **Prefer flat and serializable.** Deeply nested or non-serializable state (class instances, `Map`/`Set`/`Date`, DOM nodes, functions) is harder to diff, harder to persist, and is treated as an opaque leaf by auto-tracking — see [Dependency Tracking](/react/dependency-tracking). `patch` accepts a `DeepPartial` so shallow shapes update cleanly. Deep nesting is supported, but flat shapes are easier to reason about; reach for nesting only when the domain truly is nested. 2. **Never store what you can derive.** A derived value stored in state is a second source of truth you must remember to keep in sync. Expose it as a getter instead — auto-tracking records the state paths the getter reads, and `select` is available when a consumer should wake only when the computed result changes. ```ts // Good — totals are getters; state has one source of truth interface CartState { items: { id: string; price: number; qty: number }[]; } class CartCubit extends Cubit { constructor() { super({ items: [] }); } get subtotal() { return this.state.items.reduce((sum, i) => sum + i.price * i.qty, 0); } get itemCount() { return this.state.items.length; } } ``` ```ts twoslash // Bad — subtotal/itemCount stored alongside items; every mutation must // recompute them by hand, and one missed update silently desyncs the UI interface CartState { items: { id: string; price: number; qty: number }[]; subtotal: number; // derived — do not store itemCount: number; // derived — do not store } ``` :::note[Why getters re-render correctly] Auto-tracking records the _leaf paths_ a render reads. A getter that reads `this.state.items` is recorded as a dependency on `items`; when `items` changes the getter is re-evaluated and consumers re-render. A getter that no consumer reads costs nothing. See [Patterns & Recipes](/guide/patterns#getter-based-computed-values) for the recipe. ::: ## `args` vs `deps`: the decision rule **Principle:** put **serializable identity in `args`** and **non-serializable handles in `deps`**. Every instance is keyed from its `args` — there is no separate key input. This is the single most common source of confusion, so commit the rule to memory: | You have… | Use | Because | | ----------------------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------ | | Serializable data that defines _which_ instance (a `userId`, an `endpoint`, a filter set) | **`args`** | Args are hashed into the instance key — same args share one instance, different args fork. | | A non-serializable handle (a `useRef`, a stable `useCallback`, an external API object) | **`deps`** | Deps never key identity and are merged per-consumer; the bloc reads them lazily. | | An opaque key that isn't real bloc data (a per-mount id, an externally-managed token) | **a synthetic `args` field + `static key`** | Add the value to `args` (e.g. `_id`) and key on it; nothing else forks the instance. | The mechanics of all three live in [Passing Inputs to Blocs](/guide/inputs); this is just the judgment. Note that `deps` is **not** a `useBloc` option — a consumer contributes its slice from a mount effect via the `APPLY_DEPS` / `REMOVE_DEPS_OWNER` handles from `@blac/core` (the examples below import them); see [Wiring deps from a component](/guide/inputs#wiring-deps-from-a-component). ```tsx // Good — userId is serializable identity (args); inputRef is a handle (deps). // deps is NOT a useBloc option: wire it from a mount effect (post-commit). const inputRef = useRef(null); const ownerId = useId(); const [state, cubit] = useBloc(UserCardCubit, { args: { userId } }); useEffect(() => { cubit[APPLY_DEPS](ownerId, { inputRef }); return () => cubit[REMOVE_DEPS_OWNER](ownerId); }, [cubit, inputRef, ownerId]); ``` ```tsx // Bad — a ref in args destabilizes the instance key, forcing a brand-new // instance on every render (refs are not serializable) const [state, cubit] = useBloc(UserCardCubit, { args: { userId, inputRef }, // inputRef belongs in deps }); ``` :::caution[The inline-callback pitfall] An inline callback (`onDone={() => save()}`) has a new function identity every render. If you capture it once in `deps`, the bloc holds the **first** closure forever and later calls fire stale logic. Prefer, best first: 1. **Callback inversion** — expose result state from the bloc and let the component invoke its own fresh callback in a `useEffect` (`useEffect(() => { if (doneId) onDone(doneId); }, [doneId, onDone])`). 2. **Stabilize with `useCallback`** before passing into `deps`. 3. **Push via a bloc method from an effect** (`useEffect(() => cubit.setOnDone(cb), [cb])`). See the full treatment in [Passing Inputs to Blocs](/guide/inputs#the-callback-staleness-gotcha). ::: ### Per-component private instances When a component needs its _own_ instance with its own lifecycle (disposed on unmount), add a per-mount unique value to `args` keyed by React's `useId()` and select it with `static key` — each mount gets a fresh private instance. ```ts // Good — a synthetic `_id` keys the instance; endpoint rides along as config class FileUploadCubit extends Cubit< UploadState, { endpoint: string; _id: string } > { static key = (a: FileUploadCubit['args']) => a._id; constructor() { super({ status: 'idle', progress: 0 }); } protected init(args: { endpoint: string; _id: string }) { this.endpoint = args.endpoint; } } ``` ```tsx // Each FileUpload mount owns a private instance const _id = useId(); const [state, upload] = useBloc(FileUploadCubit, { args: { endpoint, _id } }); ``` :::tip[One source of per-mount identity] `useId()` returns a stable-per-mount value, so keying on it gives each mount a private instance that lives and dies with it. For _named_ sections that should share an instance by name (e.g. `"billing"` vs `"shipping"` form sections), make that name a real `args` field and key on it the same way. See [Instance Management](/core/instance-management) for the registry mechanics. ::: ## Async, loading, and error state **Principle:** model async outcomes as explicit state, guard against stale responses, and never block `init`. ### Make status a value, not a guess Don't infer "loading" from `data === null`. Hold an explicit status so the UI can distinguish _never loaded_, _loading_, _error_, and _empty success_. ```ts // Good — explicit, exhaustive status interface FeedState { articles: Article[]; status: 'idle' | 'loading' | 'error' | 'success'; error: string | null; } ``` ```ts // Bad — overloaded null forces the UI to guess interface FeedState { articles: Article[] | null; // null = loading? error? empty? unknown. } ``` ### Guard against overlapping requests Two in-flight loads can resolve out of order and clobber each other. A monotonic request id is simpler than `AbortController` and ignores stale responses: ```ts class FeedCubit 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.fetch(category); if (id !== this.requestId) return; // a newer load superseded us this.emit({ articles, status: 'success', error: null }); } catch (e) { if (id !== this.requestId) return; this.patch({ status: 'error', error: String(e) }); } }; } ``` ### Don't block in `init` `init(args)` runs **once, synchronously, before the first state snapshot** — so consumers get correct initial state with no flash. That makes it the wrong place to `await`. Kick the async work off (fire-and-forget) from `init` and let the loading status carry the rest. ```ts // Good — init seeds sync state and starts the load; it does not await class UserCubit extends Cubit { protected init(args: { userId: string }) { void this.load(args.userId); // returns immediately; status shows 'loading' } } ``` ```ts // Bad — init cannot be awaited by the framework; consumers render before // this resolves, and an unawaited rejection is swallowed class UserCubit extends Cubit { protected async init(args: { userId: string }) { this.emit(await api.fetchUser(args.userId)); // blocks nothing useful } } ``` :::tip[Hydrating from storage] If state arrives asynchronously from the persistence plugin, await `this.$blac.hydration.wait()` inside a fire-and-forget `init` before touching the network, so you don't overwrite restored values. See [Persistence](/plugins/persistence) and the [hydration-aware loading recipe](/guide/patterns#hydration-aware-loading). ::: ## Cross-bloc dependencies: `this.depend`, and avoid cycles **Principle:** declare cross-bloc reads with `this.depend(Other)`; keep the dependency graph a DAG. `this.depend(OtherCubit)` returns a handle that resolves the dependency from the registry on each call. Use `.untracked()` to _read_ another bloc's state inside a getter or to _call_ its methods, and `.track()` when the read should re-render the consumer (below). ```ts // Good — CartCubit depends on ShippingCubit, one direction only class CartCubit extends Cubit { private shipping = this.depend(ShippingCubit); get total() { return this.subtotal + this.shipping.untracked().state.rate; } } ``` ```ts // Bad — a cycle: Cart depends on Shipping AND Shipping depends on Cart. // Reading total now risks infinite recursion, and disposal order is undefined. class ShippingCubit extends Cubit { private cart = this.depend(CartCubit); // closes the loop — don't } ``` :::caution[Two gotchas with `depend`] - **`depend` resolves via `ensure`, which does not hold a reference.** A depended-on bloc can be disposed out from under you if nothing else keeps it alive. For app-wide collaborators, mark the dependency `@blac({ keepAlive: true })`. - **Reading a dependency's state in a constructor is unsafe** — it may not be initialized yet. Read it lazily inside a method or getter, where the getter resolves it on demand. - **`.untracked()` reads and method calls do not subscribe you.** They give you access, not reactivity. Cross-bloc re-render tracking is opt-in via `.track()` (below); a plain `.untracked()` read only updates if the consumer also subscribes via `useBloc`. See [Bloc Communication](/core/bloc-communication) for the full lifecycle picture. ::: **Reach for `.track()` when the cross-bloc read needs to be reactive.** Plain `this.shipping.untracked().state.rate` is a live but _untracked_ read — the consumer only re-renders if it independently subscribes to the dependency. When a getter genuinely derives from another bloc and components should update on its changes, call `.track()` on the handle (`const [shipping] = this.shipping.track()`) instead of duplicating `useBloc(Other)` across every consumer. Keep `.untracked()` for one-off reads and method invocations where you don't want a subscription. ```ts // Good — the derivation declares its own reactivity; consumers stay simple. class CartCubit extends Cubit { private shipping = this.depend(ShippingCubit); get total() { const [shipping] = this.shipping.track(); return this.subtotal + shipping.rate; } } ``` This keeps the dependency graph in the blocs (where it's testable) rather than scattering coordinating `useBloc` calls through the view. See [Auto-tracking with `.track()`](/core/bloc-communication#auto-tracking-with-track). If you find two blocs reaching into each other, that is usually a sign the shared concern belongs in a third bloc both depend on, or that the two should be one. Cross-bloc coupling is a smell worth questioning, not a tool to reach for first. ## Keep components dumb **Principle:** components read tracked state and call methods. They should not hold derived logic, mutate state, or own business rules. A component's job is to render the current state and forward intent. Put the _what_ in the bloc (methods, getters) and leave the _how it looks_ in the component. This keeps logic testable without a DOM and keeps re-renders precise. ```tsx // Good — reads a getter, calls a method; no logic in the view function CartSummary() { const [, cart] = useBloc(CartCubit); return (
{cart.itemCount} items ${cart.total.toFixed(2)}
); } ``` ```tsx // Bad — totals computed in the view (duplicating bloc logic), and the // button mutates state through the bloc instance directly function CartSummary() { const [state, cart] = useBloc(CartCubit); const total = state.items.reduce((s, i) => s + i.price * i.qty, 0); // belongs in a getter return (
${total.toFixed(2)} {' '} {/* never mutate */}
); } ``` :::tip[Action-only components] A component that only triggers actions and never displays state does not need a selector. Do not read from `state`, and auto-tracking records nothing to wake: ```tsx function QuickAdd() { const [, todo] = useBloc(TodoCubit); return ; } ``` The deeper performance rationale lives in [Performance](/react/performance). ::: ## Testing mindset **Principle:** test blocs as plain classes; test components against the registry. Isolate every test. Because logic lives in the bloc and components stay dumb, most behavior is testable without React — instantiate the Cubit, call methods, assert on `state` and getters. Component tests then verify wiring against an isolated registry so instances never leak between tests. ```ts // Good — pure bloc test, no DOM const cart = new CartCubit(); cart.addItem({ id: 'a', price: 10, qty: 2 }); expect(cart.subtotal).toBe(20); ``` Design blocs to be easy to test: deterministic methods, injectable collaborators via `depend`, and no hidden global reads. The full toolkit (registry isolation, stubs, overrides, flushing async updates) is in [Testing Overview](/testing/overview). ## Anti-patterns A quick-reference of habits to drop, each with the fix. Many of these have a symptom-first entry in [Troubleshooting](/guide/troubleshooting). ### Mutating state in place Assigning to `this.state.x` (or pushing into an array on `this.state`) bypasses change detection — no diff is computed, no consumer wakes. ```ts // Bad this.state.items.push(item); // Fix — produce a new value through a mutation method this.patch({ items: [...this.state.items, item] }); ``` ### Storing derived state A computed value duplicated into state is a second source of truth that drifts. ```ts // Bad — total stored and hand-maintained this.patch({ items: next, total: recompute(next) }); // Fix — expose a getter, store only the source get total() { return this.state.items.reduce(/* ... */); } ``` ### Threading identity outside `args` When the distinguishing value is _meaningful data_ (a `userId`, a `docId`), it belongs in `args` — args both key the instance and seed `init()` in one step. Don't invent a parallel key channel. ```tsx // Bad — passing userId as a bare positional/extra arg, separate from args const [s] = useBloc(UserCardCubit, userId); // Fix — args key the instance AND seed init() in one step const [s] = useBloc(UserCardCubit, { args: { userId } }); ``` For keys that aren't real bloc data — named sections (`"billing"` / `"shipping"`), externally-supplied ids, or a per-mount `useId()` — add the value as an `args` field and key on it with `static key`. There is no separate `instanceId` channel. ### Raw inline callbacks in `deps` An inline arrow captured into `deps` freezes the first render's closure. ```tsx // Bad — new identity each render contributed as a dep; bloc keeps the stale one useEffect(() => { cubit[APPLY_DEPS](ownerId, { onDone: () => save(id) }); }, [cubit, ownerId, id]); // Fix — invert: expose result state, call your own callback in an effect const [{ doneId }] = useBloc(UploadCubit, { args }); useEffect(() => { if (doneId) onDone(doneId); }, [doneId, onDone]); ``` ### Non-serializable values in `args` Refs, callbacks, class instances, and `Date`/`Map`/`Set` in `args` produce an unstable instance key — a new instance every render. ```tsx // Bad — ref in args re-keys the instance on every render useBloc(UploadCubit, { args: { endpoint, inputRef } }); // Fix — handles go in the deps lane, wired from an effect (not a useBloc option) const [, cubit] = useBloc(UploadCubit, { args: { endpoint } }); useEffect(() => { cubit[APPLY_DEPS](ownerId, { inputRef }); return () => cubit[REMOVE_DEPS_OWNER](ownerId); }, [cubit, inputRef, ownerId]); ``` ### Opting out of tracking when you don't need to Reaching for `select` "to be safe" trades automatic, leaf-precise tracking for a hand-maintained dependency array you must keep in sync. Auto-tracking is the default for a reason. ```tsx // Bad — manual select that you must remember to update when the view reads more const [s] = useBloc(TodoCubit, { select: (s) => [s.items] }); // Fix — let auto-tracking record exactly what this render reads const [s] = useBloc(TodoCubit); ``` Use `select` deliberately: to opt a writer-only component _out_ of all re-renders (`() => []`), or to narrow re-renders to a specific computed slice when profiling shows it matters — not as a default. A `select` function must also be referentially stable (wrap it in `useCallback`), or a fresh function each render re-keys the subscription. ## See also - [Passing Inputs to Blocs](/guide/inputs) — the mechanics of `args` and `deps` - [Patterns & Recipes](/guide/patterns) — concrete copy-paste recipes for these principles - [Performance](/react/performance) — re-render mechanics and the cost model - [Troubleshooting](/guide/troubleshooting) — symptom-first fixes for the anti-patterns above --- # Coming from flutter_bloc > How the flutter_bloc mental model maps onto BlaC, with concept mappings and a side-by-side counter port from Dart to TypeScript. Source: /guide/coming-from-flutter-bloc/ BlaC is a direct descendant of flutter*bloc. The name is not a coincidence: \_Business Logic Components* is the Flutter pattern, and BlaC carries the same core idea — a class owns a slice of logic and emits state — from Dart into TypeScript. If you have shipped flutter_bloc apps, most of the mental model travels straight across. What changes is idiomatic Dart vs idiomatic TypeScript, and the React binding layer. ## Concept mapping | flutter_bloc term | BlaC term | Notes | | -------------------- | --------------------------------------------------- | --------------------------------------------------------------------- | | `Cubit` | `Cubit` | Same name, same idea: extend, set initial state, expose methods | | `Bloc` | `Cubit` (no event class) | BlaC drops the `Bloc` event layer; methods _are_ the events | | `emit(state)` | `this.emit(state)` / `this.patch` / `this.update` | Same concept; BlaC adds `patch` (deep-merge) and `update` (derive) | | `BlocProvider` | Registry (automatic) | No provider tree — instances are shared via a ref-counted registry | | `BlocBuilder` | `useBloc` hook | Returns `[state, bloc]`; re-renders are auto-tracked, not `buildWhen` | | `BlocListener` | `useBloc` + `onMount` / `watch` | Side-effects in an effect or a `watch` subscription outside React | | `BlocConsumer` | `useBloc` (both roles in one hook) | State read + method call in the same component | | `MultiBlocProvider` | Nothing — just call multiple `useBloc` calls | No setup needed; each hook acquires its own instance | | `context.read()` | `useBloc(T)` (or `borrow` / `ensure` outside React) | Registry lookup by class, not Flutter `BuildContext` | | `context.watch()` | `useBloc(T)` (auto-tracks what you read) | Every `useBloc` is implicitly a "watch" | | `buildWhen` | `select` option on `useBloc` | `select: (s, b) => [s.field]` — re-render only when array changes | | `RepositoryProvider` | `Cubit` with `@blac({ keepAlive: true })` | Or pass a service as a constructor arg; no separate "repository" type | | `HydratedBloc` | Persistence plugin (`@blac/plugin-persist`) | Uses IndexedDB by default; swap adapter for React Native / other | ## The `Bloc` event layer does not exist in BlaC flutter_bloc ships two classes: `Cubit` (methods you call) and `Bloc` (events you dispatch, handlers you register). BlaC only has `Cubit`. If you used flutter's `Bloc` class, translate each `on` handler to a method: ```dart // flutter_bloc — Bloc variant class CounterBloc extends Bloc { CounterBloc() : super(0) { on((event, emit) => emit(state + 1)); on((event, emit) => emit(state - 1)); } } ``` ```ts twoslash import { Cubit } from '@blac/core'; // ---cut--- class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } increment = () => this.emit({ count: this.state.count + 1 }); decrement = () => this.emit({ count: this.state.count - 1 }); } ``` The method _is_ the event. There is no event class, no dispatch, no `add()`. You call the method directly. This is identical to flutter_bloc's `Cubit` half. ## Side-by-side port: a counter app **flutter_bloc** ```dart // cubit class CounterCubit extends Cubit { CounterCubit() : super(0); void increment() => emit(state + 1); void decrement() => emit(state - 1); } // widget tree class CounterPage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( create: (_) => CounterCubit(), child: const CounterView(), ); } } class CounterView extends StatelessWidget { const CounterView({super.key}); @override Widget build(BuildContext context) { final count = context.watch().state; return Column( children: [ Text('$count'), ElevatedButton( onPressed: () => context.read().increment(), child: const Text('+'), ), ElevatedButton( onPressed: () => context.read().decrement(), child: const Text('-'), ), ], ); } } ``` **BlaC** ```ts twoslash import { Cubit } from '@blac/core'; // ---cut--- class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } increment = () => this.emit({ count: this.state.count + 1 }); decrement = () => this.emit({ count: this.state.count - 1 }); } ``` ```tsx import { useBloc } from '@blac/react'; function Counter() { const [state, counter] = useBloc(CounterCubit); return (

{state.count}

); } ``` What changed: - No `BlocProvider` wrapping the tree — the registry handles instance sharing. - No `context.watch` / `context.read` split — `useBloc` is both. - State is an object, not a raw int (TypeScript patterns favour typed objects). - `buildWhen` is not needed; BlaC auto-tracks which fields the component reads. ## Provider tree → registry In flutter_bloc you place a `BlocProvider` in the widget tree so descendants can look up the Cubit via `context`. BlaC has no equivalent provider. The registry is a global singleton keyed on the class itself. Two calls to `useBloc(CounterCubit)` always return the same instance, regardless of where in the React tree they sit. For scoped instances — an editor with per-document state — make the scoping value part of `args` instead of using a wrapping provider: ```tsx class EditorCubit extends Cubit { static key = (a: EditorCubit['args']) => a.docId; } function Editor({ docId }: { docId: string }) { const [state, editor] = useBloc(EditorCubit, { args: { docId } }); // ... } ``` Each `docId` gets an independent `EditorCubit`. When all components using that `docId` unmount, the instance is disposed automatically (same ref-counting lifecycle as flutter_bloc's `BlocProvider` `create` + `close`). ## `BlocBuilder` → `useBloc` flutter_bloc's `BlocBuilder` drives a rebuild with `buildWhen`; `BlocConsumer` adds a `listener` for side effects. BlaC rolls both into one hook: ```dart // flutter_bloc BlocConsumer( listenWhen: (prev, curr) => prev.status != curr.status, listener: (context, state) { if (state.status == WeatherStatus.failure) { ScaffoldMessenger.of(context).showSnackBar(/*...*/); } }, buildWhen: (prev, curr) => prev.temperature != curr.temperature, builder: (context, state) => Text('${state.temperature}°'), ) ``` ```tsx import { useBloc } from '@blac/react'; import { useEffect } from 'react'; function WeatherDisplay() { const [state] = useBloc(WeatherCubit, { // re-render only when temperature changes select: (s) => [s.temperature], onMount: (bloc) => { // side effects on mount }, }); useEffect(() => { if (state.status === 'failure') { showSnackBar('Weather load failed'); } }, [state.status]); return

{state.temperature}°

; } ``` The `select` option replaces `buildWhen` (re-render when the returned array changes). Status side-effects go in a plain `useEffect`. For global, non-React listeners, use `watch`: ```ts twoslash import { Cubit } from '@blac/core'; import { watch } from '@blac/core'; class WeatherCubit extends Cubit<{ status: string; temperature: number }> { constructor() { super({ status: 'idle', temperature: 0 }); } } // ---cut--- const unwatch = watch(WeatherCubit, (bloc) => { console.log('new temp:', bloc.state.temperature); }); // call unwatch() to stop ``` ## State shape: primitives vs objects flutter_bloc commonly uses plain Dart primitives as state (`Cubit`, `Cubit`) or sealed classes. BlaC state is always an object literal in practice — TypeScript works best with typed record shapes, and `patch` only makes sense on an object: ```ts twoslash import { Cubit } from '@blac/core'; // ---cut--- // Prefer a typed object over a raw primitive class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } increment = () => this.emit({ count: this.state.count + 1 }); } ``` For discriminated-union state (the Dart sealed-class pattern), use a TypeScript union type. The view switches on `state.status` and TypeScript narrows each branch — same ergonomics as Dart `when`: ```ts twoslash import { Cubit } from '@blac/core'; declare const api: { fetchUser(id: string): Promise<{ id: string; name: string }>; }; // ---cut--- type UserState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; user: { id: string; name: string } } | { status: 'error'; message: string }; class UserCubit extends Cubit { constructor() { super({ status: 'idle' }); } load = async (id: string) => { this.emit({ status: 'loading' }); try { const user = await api.fetchUser(id); this.emit({ status: 'success', user }); } catch (e) { this.emit({ status: 'error', message: String(e) }); } }; } ``` ## Persistence: `HydratedBloc` → persist plugin flutter_bloc ships `HydratedBloc`/`HydratedCubit` for local persistence. BlaC has a first-party plugin: ```ts import { createIndexedDbPersistPlugin } from '@blac/plugin-persist'; import { getPluginManager } from '@blac/core'; const persist = createIndexedDbPersistPlugin(); getPluginManager().install(persist); ``` The persist plugin saves and restores state via IndexedDB. For React Native, swap the storage adapter (the plugin ships an interface; pass an AsyncStorage-backed adapter). See [Persistence](/plugins/persistence). ## Mental-model shift | flutter_bloc | BlaC | | -------------------------------------- | -------------------------------------------------------- | | Tree = provider scoping mechanism | Registry = global, class-keyed, ref-counted | | `context` carries bloc references | Import the class; the registry finds the instance | | `BlocBuilder` re-builds on `buildWhen` | `useBloc` re-renders on auto-tracked read paths | | `BlocListener` for side effects | `useEffect` on state values, or `watch` outside React | | `close()` called by `BlocProvider` | `release()` / ref-count-zero triggers automatic disposal | | Event objects for `Bloc` class | Method calls — no dispatch, no `add()` | If you used `Cubit` in Flutter (not the full `Bloc` event layer), migrating to BlaC is mostly syntax translation. If you used `Bloc` events, collapse each `on` handler into a method. ## See also - [Core Concepts](/guide/concepts) — state containers, registry, dependency tracking - [Comparison](/guide/comparison) — BlaC vs Zustand vs Jotai, including the flutter_bloc lineage - [useBloc](/react/use-bloc) — full hook reference with `args`, `select`, `onMount` - [Async](/guide/async) — async methods, status unions, cancellation - [Persistence](/plugins/persistence) — the persist plugin and storage adapters --- # Coming from Redux (Toolkit) > How each Redux Toolkit primitive maps onto BlaC, with a full side-by-side todo-list port and notes on when to stay with Redux. Source: /guide/coming-from-redux/ Redux and BlaC share the same design principle: a single source of truth per concern, immutable state updates, and first-party DevTools support. The difference is in the mechanism. Redux routes every mutation through a dispatcher and a reducer; BlaC routes it through a method on a class. The result is less indirection, less boilerplate, and auto-tracked re-renders — but the tradeoff is giving up Redux's strict, serializable action log. If you use Redux Toolkit today, this page maps each RTK primitive to its BlaC equivalent and walks through a full side-by-side port. ## Concept mapping | Redux / RTK term | BlaC term | Notes | | ------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- | | `createSlice({ name, ... })` | `class MyCubit extends Cubit` | The class is the slice: state type, initial state, and mutations in one | | `initialState` | `super(initialState)` in the constructor | Passed to the parent class | | `reducers: { action: fn }` | Method on the class | `action(payload)` is the combined action-creator + reducer | | `createAsyncThunk` | `async` method on the class | No thunk factory; just `async method = async () => { ... }` | | `extraReducers` / `builder` | Additional methods or `onSystemEvent` | No separate builder step; add more methods to the class | | `dispatch(action())` | `cubit.method(args)` / `bloc.method(args)` | Call the method directly; no dispatcher | | `useSelector((s) => s.slice.x)` | `useBloc(MyCubit)` reading `state.x` | No selector written; the read during render _is_ the subscription | | `useDispatch()` | Second element of `useBloc` tuple | `const [state, cubit] = useBloc(MyCubit); cubit.method()` | | `configureStore({ reducer })` | Registry (automatic) | No store setup; instances live in the global ref-counted registry | | `Provider` wrapping the app | Nothing — registry is implicit | No provider needed | | `createEntityAdapter` | Class with typed state + methods | Model a collection as `items: Record` in the state object | | `RTK Query` | Async method + status union | BlaC does not ship a query layer; see [Async](/guide/async) for the pattern | | Redux DevTools | `@blac/devtools-connect` plugin | First-party; inspects every Cubit, time-travel, state diff | | Middleware | Plugins (`@blac/devtools-connect`, `@blac/plugin-persist`, ...) | Installed once globally; observe all Cubits | ## The dispatch/reducer indirection does not exist in BlaC RTK's slice defines reducers keyed by action type; `dispatch` routes incoming action objects to the matching reducer. BlaC removes the intermediary. The action name _is_ the method name; the reducer _is_ the method body; and calling the method _is_ the dispatch. One step instead of three. ```ts // RTK — three artifacts per mutation const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { increment: (state) => { state.value += 1; }, // reducer incrementByAmount: (state, action) => { state.value += action.payload; // reducer + payload type }, }, }); export const { increment, incrementByAmount } = counterSlice.actions; // action creators export default counterSlice.reducer; ``` ```ts twoslash import { Cubit } from '@blac/core'; // ---cut--- // BlaC — one artifact class CounterCubit extends Cubit<{ value: number }> { constructor() { super({ value: 0 }); } increment = () => this.emit({ value: this.state.value + 1 }); incrementByAmount = (amount: number) => this.emit({ value: this.state.value + amount }); } ``` No separate file for actions. No `export const { ... }`. No `reducer` export to wire into a store. ## Immutable updates: Immer vs BlaC RTK ships Immer so reducers can write `state.value += 1` (mutable syntax compiled to immutable updates). BlaC state is always immutable: `emit(next)` and `update(fn)` replace the whole state object, and `patch(partial)` deep-merges a partial. You never mutate `this.state` in place: ```ts twoslash import { Cubit } from '@blac/core'; interface Item { id: string; name: string; } // ---cut--- // RTK would let you write: state.items.push(item) // BlaC — always return a new value class ListCubit extends Cubit<{ items: Item[] }> { constructor() { super({ items: [] }); } add = (item: Item) => this.patch({ items: [...this.state.items, item] }); remove = (id: string) => this.patch({ items: this.state.items.filter((i) => i.id !== id) }); } ``` `patch` deep-merges, so you only mention the key you are changing. `emit` and `update` replace the whole state — list every key, or spread the previous state. ## Side-by-side port: a todo list **Redux Toolkit** ```ts // slice import { createSlice, PayloadAction } from '@reduxjs/toolkit'; interface TodoItem { id: number; text: string; completed: boolean; } interface TodoState { items: TodoItem[]; nextId: number; } const todoSlice = createSlice({ name: 'todos', initialState: { items: [], nextId: 1 } as TodoState, reducers: { addTodo: (state, action: PayloadAction) => { state.items.push({ id: state.nextId++, text: action.payload, completed: false, }); }, toggleTodo: (state, action: PayloadAction) => { const todo = state.items.find((t) => t.id === action.payload); if (todo) todo.completed = !todo.completed; }, removeTodo: (state, action: PayloadAction) => { state.items = state.items.filter((t) => t.id !== action.payload); }, }, }); export const { addTodo, toggleTodo, removeTodo } = todoSlice.actions; export default todoSlice.reducer; ``` ```tsx // component import { useSelector, useDispatch } from 'react-redux'; function TodoList() { const items = useSelector((s: RootState) => s.todos.items); const dispatch = useDispatch(); return (
    {items.map((t) => (
  • dispatch(toggleTodo(t.id))} /> {t.text}
  • ))}
); } ``` **BlaC** ```ts twoslash import { Cubit } from '@blac/core'; // ---cut--- interface TodoItem { id: number; text: string; completed: boolean; } class TodoCubit extends Cubit<{ items: TodoItem[]; nextId: number }> { constructor() { super({ items: [], nextId: 1 }); } addTodo = (text: string) => { const { items, nextId } = this.state; this.emit({ items: [...items, { id: nextId, text, completed: false }], nextId: nextId + 1, }); }; toggleTodo = (id: number) => this.patch({ items: this.state.items.map((t) => t.id === id ? { ...t, completed: !t.completed } : t, ), }); removeTodo = (id: number) => this.patch({ items: this.state.items.filter((t) => t.id !== id) }); } ``` ```tsx import { useBloc } from '@blac/react'; function TodoList() { const [state, todos] = useBloc(TodoCubit); return (
    {state.items.map((t) => (
  • todos.toggleTodo(t.id)} /> {t.text}
  • ))}
); } ``` What changed: - No `createSlice`, no `PayloadAction`, no action-creator exports. - No `configureStore`, no `Provider`, no `RootState` type. - `useSelector` + `useDispatch` collapse into a single `useBloc` call. - Re-renders are auto-tracked: `TodoList` wakes only when `items` changes (not when `nextId` does). ## Async: `createAsyncThunk` → async method RTK's thunk factory adds a lifecycle (`pending` / `fulfilled` / `rejected`) dispatched as separate action objects. BlaC async is a plain `async` method that calls `emit` as it goes: ```ts // RTK export const fetchUser = createAsyncThunk('user/fetch', async (id: string) => { const response = await api.fetchUser(id); return response.data; }); const userSlice = createSlice({ name: 'user', initialState: { status: 'idle', user: null, error: null }, reducers: {}, extraReducers: (builder) => { builder .addCase(fetchUser.pending, (state) => { state.status = 'loading'; }) .addCase(fetchUser.fulfilled, (state, action) => { state.status = 'success'; state.user = action.payload; }) .addCase(fetchUser.rejected, (state, action) => { state.status = 'error'; state.error = action.error.message ?? null; }); }, }); ``` ```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' }); } fetchUser = async (id: string) => { const reqId = ++this.requestId; this.emit({ status: 'loading' }); try { const user = await api.fetchUser(id); if (reqId !== this.requestId) return; this.emit({ status: 'success', user }); } catch (e) { if (reqId !== this.requestId) return; this.emit({ status: 'error', message: String(e) }); } }; } ``` The `requestId` guard replaces RTK's thunk cancellation — a newer call wins and the older one drops its result. No `AbortController` needed for this pattern, though BlaC supports it too. ## Selectors → auto-tracked reads RTK encourages `createSelector` (Reselect) to derive and memoize values from the store: ```ts // RTK + Reselect export const selectTotal = createSelector( (s: RootState) => s.cart.items, (items) => items.reduce((sum, i) => sum + i.price * i.qty, 0), ); ``` BlaC derives values in a getter on the class. Auto-tracking records the getter's underlying reads, so a component can read `cart.total` and stay subscribed to the source paths without a separate selector: ```ts twoslash import { Cubit } from '@blac/core'; interface CartItem { id: string; price: number; qty: number; } // ---cut--- class CartCubit extends Cubit<{ items: CartItem[] }> { constructor() { super({ items: [] }); } get total() { return this.state.items.reduce((sum, i) => sum + i.price * i.qty, 0); } } ``` A component that reads `cart.total` during render re-renders when the getter's source paths change — no memoization layer, no Reselect import. Use `select` if the computed total itself should gate re-renders. ## DevTools Redux DevTools is the standard time-travel inspector for Redux and RTK. BlaC ships `@blac/devtools-connect` as a first-party plugin: ```ts import { getPluginManager } from '@blac/core'; import { createDevToolsBrowserPlugin } from '@blac/devtools-connect'; getPluginManager().install(createDevToolsBrowserPlugin(), { environment: 'development', }); ``` The plugin shows every Cubit's state changes, diffs, and method calls in the same Redux DevTools panel. State is diffed at the field level; you can step forward and backward through mutations. ## Store setup: `configureStore` → nothing RTK requires a `configureStore` call to wire reducers, and a `Provider` at the tree root: ```tsx // RTK setup const store = configureStore({ reducer: { counter: counterSlice.reducer, todos: todoSlice.reducer }, }); function App() { return ( ); } ``` BlaC has no equivalent. The registry is global, implicit, and automatic. Components call `useBloc` and the registry creates instances on first use, shares them, and disposes them when the last consumer unmounts. No bootstrap, no provider tree. ## When to stay with Redux Redux's strict serializable action log is a genuine architectural choice, not just boilerplate. Reach for it when: - Your team needs a comprehensive audit trail of every state transition (e.g. regulated industries, complex undo/redo flows over many slices). - You have large-team conventions and tooling already built around RTK (code generators, lint rules, saga/observable middleware). - RTK Query is doing meaningful work for you (caching, deduplication, polling). For most product apps where "I need shared, testable state logic" is the driver, BlaC removes the ceremony without removing the testability or the DevTools story. ## Mental-model shift | Redux / RTK | BlaC | | ------------------------------------ | -------------------------------------------------------- | | `createSlice` + `configureStore` | `class MyCubit extends Cubit` (no setup step) | | `dispatch(action(payload))` | `cubit.method(payload)` — direct call | | Reducer handles one action type | Method _is_ the reducer | | `useSelector((s) => ...)` per hook | Auto-tracked read during render — no selector written | | `useDispatch()` + action-creator | Second element of `useBloc` tuple | | `Provider` wraps the app | No provider — registry is implicit | | `createAsyncThunk` + `extraReducers` | `async method` with inline `emit` calls | | Reselect / `createSelector` | Getter on the class; no memoization layer needed | | Middleware chain | Plugin list installed once globally | | Single global store | Many independent Cubits; each ref-counted, auto-disposed | ## See also - [Comparison](/guide/comparison) — BlaC vs Zustand vs Jotai, with Redux in the honest-comparisons table - [Core Concepts](/guide/concepts) — state containers, registry, dependency tracking - [useBloc](/react/use-bloc) — full hook reference with `args`, `select`, `onMount` - [Async](/guide/async) — async methods, status unions, cancellation, and why BlaC skips Suspense - [DevTools](/plugins/devtools) — first-party BlaC DevTools plugin --- # Coming from Zustand > How Zustand's no-provider store maps onto BlaC, where logic lives, how re-renders are scoped, and a side-by-side bear-counter port. Source: /guide/coming-from-zustand/ Zustand and BlaC share the same "no provider" philosophy and a minimal surface area. The divergence is in _where logic lives_ (a closure vs a class), _how re-renders are scoped_ (an explicit selector vs auto-tracked read paths), and _what you get for free as complexity grows_ (a flat store vs a typed unit you can test in isolation). If you are comfortable with Zustand but find yourself writing many selectors, leaking logic into components, or struggling to test state mutations, BlaC is a natural next step. ## Concept mapping | Zustand term | BlaC term | Notes | | ---------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------- | | `create((set, get) => ...)` | `class MyCubit extends Cubit` | Logic lives in the class body instead of a closure | | `set(partial)` | `this.patch(partial)` / `this.emit(next)` | `patch` deep-merges; `emit` replaces | | `get()` | `this.state` | Read current state from the instance property | | `useStore(selector)` | `useBloc(MyCubit)` + auto-tracked `state` | No selector needed; tracking is inferred from what the render reads | | `useStore((s) => s.count)` | `useBloc(MyCubit)` reading `state.count` | Reading `state.count` in render _is_ the subscription | | Middleware (`devtools`, `persist`) | First-party plugins (`@blac/devtools-connect`, `@blac/plugin-persist`) | Plugin API is explicit; installed once globally | | `subscribeWithSelector` | `watch(MyCubit, cb)` | Outside React; no middleware needed | | `createWithEqualityFn` | `select` option on `useBloc` | `select: (s, b) => [s.derived]` — re-render only when array changes | | Slices pattern (`combine`) | Separate `Cubit` per concern | Each Cubit is already a self-contained slice | | Immer middleware | `patch(partial)` (built-in deep-merge) | Or use spread in `update` — no middleware required | ## The key model difference Zustand stores logic in a `create()` closure. The object it returns is both the state and the actions — a flat record with properties and function values mixed together. Re-render scoping requires an explicit selector passed to every hook call. BlaC separates the concerns: state is typed separately from the class body, methods are class methods, and the hook returns a `[state, bloc]` tuple. Because the hook wraps `state` in a Proxy during render, it records which paths the component reads — no selector needed. ## Side-by-side port: a bear counter **Zustand** ```tsx import { create } from 'zustand'; interface BearStore { bears: number; honey: number; increasePopulation: () => void; eatHoney: () => void; reset: () => void; } const useBearStore = create((set) => ({ bears: 0, honey: 10, increasePopulation: () => set((s) => ({ bears: s.bears + 1 })), eatHoney: () => set((s) => ({ honey: Math.max(0, s.honey - 1) })), reset: () => set({ bears: 0, honey: 10 }), })); function BearCounter() { // explicit selector — component only re-renders when bears changes const bears = useBearStore((s) => s.bears); const increase = useBearStore((s) => s.increasePopulation); return ; } function HoneyJar() { const honey = useBearStore((s) => s.honey); const eat = useBearStore((s) => s.eatHoney); return ; } ``` **BlaC** ```ts twoslash import { Cubit } from '@blac/core'; // ---cut--- class BearCubit extends Cubit<{ bears: number; honey: number }> { constructor() { super({ bears: 0, honey: 10 }); } increasePopulation = () => this.patch({ bears: this.state.bears + 1 }); eatHoney = () => this.patch({ honey: Math.max(0, this.state.honey - 1) }); reset = () => this.emit({ bears: 0, honey: 10 }); } ``` ```tsx import { useBloc } from '@blac/react'; function BearCounter() { // reading state.bears → component re-renders only when bears changes const [state, bear] = useBloc(BearCubit); return ( ); } function HoneyJar() { // reading state.honey → re-renders only when honey changes const [state, bear] = useBloc(BearCubit); return ; } ``` `BearCounter` and `HoneyJar` re-render independently — each only wakes on the path it actually reads. With Zustand you would write two `useStore((s) => s.x)` selectors by hand. With BlaC the read _is_ the subscription; no selector required. ## Where logic grows The flat closure model works well for small stores. As a slice accumulates validation, derived values, and async flows, the Zustand pattern collapses everything into one growing object literal. The BlaC Cubit keeps those concerns in class methods and getters: **Zustand — a growing store** ```ts const useCartStore = create((set, get) => ({ items: [], addItem: (item) => set((s) => ({ items: [...s.items, item] })), removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })), // getter-like value mixed into the store shape get total() { return get().items.reduce((sum, i) => sum + i.price * i.qty, 0); }, checkout: async () => { const items = get().items; await api.checkout(items); set({ items: [] }); }, })); ``` **BlaC — the same cart** ```ts twoslash import { Cubit } from '@blac/core'; interface CartItem { id: string; price: number; qty: number; } declare const api: { checkout(items: CartItem[]): Promise }; // ---cut--- class CartCubit extends Cubit<{ items: CartItem[] }> { constructor() { super({ items: [] }); } addItem = (item: CartItem) => this.patch({ items: [...this.state.items, item] }); removeItem = (id: string) => this.patch({ items: this.state.items.filter((i) => i.id !== id) }); get total() { return this.state.items.reduce((sum, i) => sum + i.price * i.qty, 0); } checkout = async () => { await api.checkout(this.state.items); this.emit({ items: [] }); }; } ``` The Cubit is testable without React or a mock store wrapper: ```ts twoslash import { Cubit } from '@blac/core'; interface CartItem { id: string; price: number; qty: number; } class CartCubit extends Cubit<{ items: CartItem[] }> { constructor() { super({ items: [] }); } addItem = (item: CartItem) => this.patch({ items: [...this.state.items, item] }); get total() { return this.state.items.reduce((sum, i) => sum + i.price * i.qty, 0); } } // ---cut--- const cart = new CartCubit(); cart.addItem({ id: 'a', price: 10, qty: 2 }); cart.addItem({ id: 'b', price: 5, qty: 1 }); console.log(cart.total); // 25 ``` No `act()`, no render harness, no `getState()` reached through a store handle. ## Middleware → plugins Zustand middleware wraps the store creator (devtools, persist, immer). BlaC uses a plugin system installed once globally: ```ts import { getPluginManager } from '@blac/core'; import { createDevToolsBrowserPlugin } from '@blac/devtools-connect'; import { createIndexedDbPersistPlugin } from '@blac/plugin-persist'; import { LoggingPlugin } from '@blac/logging-plugin'; getPluginManager().install(createDevToolsBrowserPlugin(), { environment: 'development', }); getPluginManager().install(createIndexedDbPersistPlugin()); getPluginManager().install(new LoggingPlugin({ level: 'info' }), { environment: 'development', }); ``` No middleware composition, no `devtools(persist(immer(...)))` nesting. Plugins observe all Cubits globally; you can opt individual Cubits out via `@blac({ excludeFromDevTools: true })`. ## Subscribing outside React Zustand's `subscribeWithSelector` and its vanilla `store.subscribe` travel to BlaC's `watch`: ```ts twoslash import { Cubit } from '@blac/core'; import { watch } from '@blac/core'; class BearCubit extends Cubit<{ bears: number; honey: number }> { constructor() { super({ bears: 0, honey: 10 }); } } // ---cut--- const unwatch = watch(BearCubit, (bloc) => { document.title = `Bears: ${bloc.state.bears}`; }); // later: unwatch(); ``` `watch` observes a Cubit's state from outside React. No store handle, no selector middleware needed. ## Slices → separate Cubits The Zustand slices pattern (`combine`, `createSlice`) splits one large store into sections that are merged back together. BlaC separates concerns at the class level — each Cubit is already an independent slice. Cross-cubit access uses `this.depend()`: ```ts twoslash import { Cubit } from '@blac/core'; class AuthCubit extends Cubit<{ user: string | null }> { constructor() { super({ user: null }); } } // ---cut--- class CartCubit extends Cubit<{ items: string[] }> { private auth = this.depend(AuthCubit); constructor() { super({ items: [] }); } checkout = async () => { const authState = this.auth.untracked().state; if (!authState.user) throw new Error('Not logged in'); // ... proceed }; } ``` No slice merging, no shared-store handle. `depend` returns a handle that resolves the Cubit from the registry, keeping the two slices decoupled. ## Re-render scoping: selector vs auto-track The most common Zustand pattern is a per-hook selector: ```tsx // Zustand — every subscription needs a selector const count = useCounterStore((s) => s.count); const name = useUserStore((s) => s.profile.name); ``` BlaC infers the subscription from the render read: ```tsx // BlaC — read it, that's the subscription const [counterState] = useBloc(CounterCubit); const [userState] = useBloc(UserCubit); // count = counterState.count, name = userState.profile.name // no selectors written ``` When you do need finer control — a computed value, a cross-field condition — use the `select` option: ```tsx const [state] = useBloc(CartCubit, { select: (s, cart) => [cart.total], // re-render only when total changes }); ``` ## Mental-model shift | Zustand | BlaC | | ------------------------------------------- | ------------------------------------------------------ | | `create()` closure holds state + actions | Class body holds state type + methods | | Explicit selector per hook call | Auto-tracked read paths; `select` for computed values | | Middleware stack (`devtools(persist(...))`) | Plugin list installed once globally | | `subscribeWithSelector` for outside-React | `watch(Class, cb)` — no middleware needed | | Slices merged via `combine` | Independent Cubits; cross-cubit via `depend()` | | Mutations tested via `getState()` | Mutations tested by calling methods on `new MyCubit()` | | `immer` middleware for nested merges | `patch(partial)` built in | ## When to stay with Zustand BlaC earns its weight when state accumulates logic worth testing, derived values, or async flows. If your state is a handful of booleans in a flat object that never grows a method, Zustand's closure is the lower-overhead choice. See [When to use BlaC](/guide/introduction#when-to-use-blac). ## See also - [Comparison](/guide/comparison) — BlaC vs Zustand vs Jotai side by side, including when Zustand is the better fit - [Core Concepts](/guide/concepts) — state containers, registry, dependency tracking - [useBloc](/react/use-bloc) — full hook reference with `args`, `select`, `onMount` - [Dependency Tracking](/react/dependency-tracking) — auto-tracking and `select` in depth - [Patterns & Recipes](/guide/patterns) — cross-bloc deps, persistence, async --- # Comparison > An honest, side-by-side look at how BlaC compares to Zustand and Jotai, and when each is the better choice. Source: /guide/comparison/ If you are evaluating BlaC against Zustand or Jotai, the first question is usually skeptical: _why a class?_ Hooks-first stores feel lighter, and "OOP for state" reads like a step backwards. This page makes the affirmative case first, then puts the three side by side honestly — including the cases where Zustand or Jotai is the better choice. This is positioning, not a feature scoreboard. None of these libraries is wrong; they make different bets. The goal here is to make BlaC's bet legible so you can tell whether it is yours. ## Why a class is the unit of logic The class is not ceremony for its own sake. Each thing it gives you is a direct answer to a recurring pain in store-closure or atom-graph designs. **Logic is colocated with the state it mutates.** State shape, the methods that change it, derived values, and async flows live in one cohesive unit. A `CartCubit` holds `items` and _also_ holds `addItem`, `removeItem`, `checkout`, and `get total`. You do not chase a mutation across a reducer file, an action-creators file, and a selectors file — the concern is one class. When the cart's rules change, you edit one place. **You can test it without React.** A Cubit has no dependency on React, the DOM, or hooks. You construct it, call methods, and assert on `state` and getters directly — a plain unit test, no render harness, no `act()`, no test renderer: ```ts const cart = new CartCubit(); cart.addItem({ id: 'a', price: 10, qty: 2 }); expect(cart.total).toBe(20); ``` Store-closure designs can be tested headless too, but it is less direct: you reach through the store's `getState`/`setState` rather than calling a method on an object, and shared mutable module state between tests is easy to leak. A fresh `new CartCubit()` per test is the natural isolation boundary. **Getters are derived state, for free.** A `get total()` recomputes from `items` on every read, so it can never drift from its source. The tracker records the getter's underlying reads, so a component that reads `cart.total` wakes when the source paths the getter touched change — no `useMemo`, no `reselect`, no memo input arrays to keep in sync. Use `select` when the computed result itself should gate re-renders. Derived state in an atom library is its own atom you compose and wire; here it is a method body. **The lineage is `flutter_bloc`, deliberately.** "Business Logic Component" is the Flutter pattern: a class that owns a slice of logic and emits state. BlaC keeps the `Cubit` half of that lineage (methods you call, not events you dispatch — there is no `Bloc` event class; see [Best Practices](/guide/best-practices)) because the testability and colocation win travels straight across from Flutter to React. **Re-renders are per consumer, automatically.** Each `useBloc` call site gets its own render-time proxy and its own recorded set of read paths. Two components reading the same instance subscribe to different fields and wake independently — without you writing a selector per subscription. The dependency declaration _is_ the JSX. The full mechanism is in [Mental Model](/guide/mental-model). :::note[This is a real tradeoff] A class is heavier than a `create((set) => ...)` closure or a one-line atom for trivial state. If your state is a single boolean or a counter that never grows logic, that weight is not buying you anything. BlaC earns its keep as a slice of state accumulates _mutations, derived values, and async_ — not at the first `useState`. ::: ## At a glance A fixed rubric across the three. Read it as "how each library answers this question," not as winners and losers. | | **BlaC** | **Zustand** | **Jotai** | | ----------------------- | ----------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------- | | **State model** | Class (`Cubit`) with methods + getters | `create()` store closure with `set`/`get` | Composable atoms (primitive + derived) | | **Render optimization** | Auto-tracked read paths, per consumer (no selector) | Selector you pass to the hook (`useStore(s => …)`) | Per-atom subscription; granularity from atom split | | **Boilerplate** | Class + `super(initial)`; methods are intents | Minimal; one closure | Minimal per atom; grows with atom count + wiring | | **Providers** | None — global ref-counted registry | None by default (optional context store) | `Provider` for scoping/SSR (optional at root) | | **TS inference** | State flows from the class type param | Strong; sometimes needs a typed `create()` | Strong; derived-atom types inferred | | **Async** | `async` methods on the class; explicit status state | Async actions in the store closure | Async atoms (promise atoms, Suspense-friendly) | | **DevTools** | First-party `@blac/devtools-connect` plugin | Redux DevTools middleware | Jotai DevTools / Redux DevTools integration | | **SSR** | Per-request isolation via instance keys / registry | Supported; hydrate store on the client | Strong story via `Provider` + hydration | | **Framework-agnostic** | Core is framework-agnostic; React adapter is separate | Core is framework-agnostic; vanilla + React | React-centric (Jotai core targets React) | | **Bundle size** | Measured: core ~6.88 kB, react ~2.6 kB (brotli) | Tiny; see their published claims | Tiny core; grows with utility imports | Bundle-size detail is in [its own section below](#bundle-size) — only BlaC's number is measured here; the others are described qualitatively on purpose. ## Honest comparisons BlaC borrows liberally and differs deliberately. Here is where it sits relative to tools you likely know — including Redux, MobX, and Context for completeness — and when one of them is the better fit. | Library | What BlaC borrows | What BlaC does differently | Reach for it instead when | | ------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | **Redux (Toolkit)** | Single source of truth per concern; immutable updates; a devtools/time-travel story | No global reducer/action/dispatch indirection; logic is methods on a class, not reducers + action creators; auto-tracking replaces hand-written selectors | You need a strict, serializable action log as the center of your architecture, or large-team conventions built around RTK | | **Zustand** | No-provider, hook-first store; minimal API | State lives in a class with methods and getters (not a `create((set) => ...)` closure); re-render scope is per-read-path automatically, not a selector you pass to the hook | You want the smallest possible store with no class ceremony and are happy writing selectors per subscription | | **MobX** | Read-to-subscribe transparent reactivity; derived values feel free | Reactivity is render-time path recording over **immutable** snapshots, not observable mutable objects with autorun; you replace state, you don't mutate it; no decorators required for tracking | You want deep observable graphs with `computed`/`reaction` and prefer mutate-in-place ergonomics | | **React Context** | Tree-free _consumption_ ergonomics (just call a hook) | Sharing is registry identity, not provider position; no subtree re-render on change; instances are ref-counted and disposable | The value is genuinely tree-scoped config (theme, locale, a request) that should follow the component tree, change rarely, and never needs disposal | | **Jotai / Recoil** | Fine-grained, atom-like subscription granularity | Granularity comes from _which paths you read in one state object_, not from composing many atoms; one cohesive class instead of a graph of atoms | You think in independent composable atoms and want bottom-up derived-atom graphs | What to take from the table: BlaC's 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. If your problem is genuinely a serializable event log, a tree-scoped config value, or a handful of `useState` hooks, the honest answer is that one of those tools fits better — see the "when to use BlaC" framing in the [Introduction](/guide/introduction#when-to-use-blac). ## The same counter, three ways A counter is too small to _need_ any of these libraries — that is exactly why it isolates the shape of each model. Watch where the logic lives and how a component subscribes. **Zustand** — logic in a `create()` closure; the component passes a selector to scope its re-renders. ```tsx import { create } from 'zustand'; const useCounter = create<{ count: number; increment: () => void; }>((set) => ({ count: 0, increment: () => set((s) => ({ count: s.count + 1 })), })); function Counter() { const count = useCounter((s) => s.count); const increment = useCounter((s) => s.increment); return ; } ``` **Jotai** — state and its updater are atoms; the component subscribes to the atom it reads. ```tsx import { atom, useAtom } from 'jotai'; const countAtom = atom(0); function Counter() { const [count, setCount] = useAtom(countAtom); return ; } ``` **BlaC** — logic in a class; the component reads `state.count` and that read _is_ the subscription. ```ts twoslash import { Cubit } from '@blac/core'; // ---cut--- class CounterCubit extends Cubit<{ count: number }> { constructor() { super({ count: 0 }); } increment = () => this.emit({ count: this.state.count + 1 }); } ``` ```tsx import { useBloc } from '@blac/react'; function Counter() { const [state, counter] = useBloc(CounterCubit); return ; } ``` The three converge on roughly the same line count for a counter. The divergence shows up as logic grows: the Zustand closure and the Jotai atom set accumulate updaters and derived atoms inline, while the Cubit accumulates methods and getters as a typed unit you can test in isolation. The render-scoping line — `useCounter((s) => s.count)` vs `useAtom(countAtom)` vs an untouched `state.count` read — is the per-consumer-isolation difference made literal: BlaC infers it from the read, the other two make it explicit (a selector, or which atom you reach for). ## Which one to choose **Choose BlaC when** state is _shared, complex, and worth testing without React_: validation, derived values, async flows, cross-bloc coordination. You want logic colocated in a typed unit, automatic per-read re-render scoping, and a ref-counted lifecycle with no providers — and you are comfortable with classes as the organizing idea. **Choose Zustand when** you want the smallest possible store with no class ceremony, are happy writing a selector per subscription to scope re-renders, and your logic stays close to a flat closure. It is an excellent floor for "I just need a shared store." **Choose Jotai when** you think bottom-up in independent, composable pieces — derived-atom graphs, fine-grained Suspense-friendly async, state that is naturally a web of small values rather than a few cohesive slices. Atoms shine when the composition _is_ the model. If a piece of state lives in one component and never travels, none of the three earns its weight — reach for `useState`. See [When to use BlaC](/guide/introduction#when-to-use-blac). ## Bundle size BlaC's footprint is **measured in CI with [size-limit](https://github.com/ai/size-limit)** on the published ESM build, brotli-compressed: - **`@blac/core`** is about **6.88 kB** (brotli). - **`@blac/react`** is about **2.6 kB** (brotli), excluding `react` / `react-dom` peers. These are the real numbers from the size budget that gates every build, not estimates. A React app pulls in both, so the floor is roughly **9.5 kB** before your own code. For Zustand and Jotai, consult their own published figures rather than any number quoted here. Both are deliberately small — Zustand advertises a tiny core, and Jotai's primitives are minimal — but a precise byte count depends on which utilities and middleware you import, your bundler, and your compression settings, so inventing exact competitor numbers would be dishonest. The fair summary: all three are small enough that bundle size is rarely the deciding factor between them. Pick on model fit, not bytes. ## See also - [Mental Model](/guide/mental-model) — why auto-tracking, the registry, and immutable emit work the way they do - [Introduction](/guide/introduction#when-to-use-blac) — what BlaC is and when it earns its weight - [Best Practices](/guide/best-practices) — Cubit-vs-Bloc, state shape, and the input lanes - [Dependency Tracking](/react/dependency-tracking) — auto-track vs `select` in practice --- # 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). For one-line definitions of every term, see the [Glossary](/guide/glossary). ## 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 Every term used above — `StateContainer`, `Cubit`, registry, ref counting, auto-tracking, `args`/`deps`, hydration, `depend()`, and the rest — has a one-line definition in the [Glossary](/guide/glossary), each linking to its deep page. ## 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 - [Patterns & Recipes](/guide/patterns) — Common patterns for structuring your app - [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 - [Glossary](/guide/glossary) — definitions for every term here - [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 / pre-release] BlaC v2 is in pre-release (beta). While in beta, **breaking API changes may ship in patch releases** without a major version bump — pin exact versions and check the changelog before upgrading. Strict semver resumes once v2 is officially out of beta. ::: ## 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 [Patterns & Recipes](/guide/patterns). :::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?" - [Patterns & Recipes](/guide/patterns) — Async patterns, cross-bloc communication, persistence - [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 --- # Glossary > A one-line definition for every term in the BlaC docs, grouped and alphabetized, with a link to the page that explains each in full. Source: /guide/glossary/ A one-line definition for every term you will meet in the BlaC docs, with a link to the page that explains it in full. Terms are grouped into **Core model**, **Inputs & identity**, **React binding**, **Lifecycle & hydration**, and **Plugins & observation**, then alphabetized within each group. :::tip[Read this when terms collide] A few names look alike and are easy to confuse: `select` (a re-render selector) vs `deps` (a handle lane); and `StateContainer` vs `Cubit` vs "bloc" vs "instance". The [Disambiguation](#disambiguation) section at the bottom untangles each cluster in one place. ::: ## Core model | Term | Definition | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **StateContainer** | The abstract base class for all state holders. Holds state, exposes `emit`/`patch`/`update`, manages subscriptions, deps, and lifecycle. You rarely extend it directly. See [Cubit](/core/cubit). | | **Cubit** | The concrete class you extend. It is `StateContainer` with an empty body, existing as a real class so `instanceof Cubit` works. Adds nothing structurally. See [Cubit](/core/cubit). | | **bloc** | Colloquial shorthand for _any_ state-container instance (`StateContainer` or `Cubit`). There is **no `Bloc` class** in BlaC. See [Concepts](/guide/concepts). | | **instance** | A single live object of a bloc class, identified by its instance key and shared by all consumers that resolve to that key. See [Instance management](/core/instance-management). | | **state** | The single object a bloc holds (`S extends object`). Read via `bloc.state`; replaced or merged through `emit`/`patch`/`update`. See [Cubit](/core/cubit). | | **emit** | `emit(next)` replaces the whole state with `next`. Skipped when `next` is reference-equal or passes the configured equality fn. See [Cubit](/core/cubit). | | **patch** | `patch(partial)` deep-merges a `DeepPartial` into state, marking only paths whose value actually changed. The equality fn does **not** apply to `patch`. See [Cubit](/core/cubit). | | **update** | `update(fn)` is sugar for `emit(fn(state))` — build the next state from the current one. See [Cubit](/core/cubit). | | **init** | `protected init(args)` — an override hook called **once** after construction, before the first snapshot. Seed args-derived state or kick off loads here. See [Patterns](/guide/patterns). | | **registry** | The global singleton that creates, keys, shares, ref-counts, and disposes instances. Resolved via `getRegistry()`. See [Instance management](/core/instance-management). | | **acquire / release** | Registry verbs that take and give back a counted reference to an instance: `acquire` creates-or-reuses and bumps the ref count, `release` drops it (disposing at zero unless `keepAlive`). See [Instance management](/core/instance-management). | | **ensure** | Registry verb that creates-or-reuses an instance **without** taking a ref. Used by `watch` and `depend()`; the result is not kept alive by the caller. See [Instance management](/core/instance-management). | | **borrow / borrowSafe** | Registry reads that return an **existing** instance without creating one or counting a ref. `borrow` throws when missing; `borrowSafe` returns `{ error, instance }`. See [Instance management](/core/instance-management). | | **ref counting** | The mechanism behind sharing: each consumer holds one ref via `acquire`; when the last ref is released the instance is auto-disposed (unless `keepAlive`). See [Instance management](/core/instance-management). | | **instance key** | The string that decides which instance a consumer gets. Resolved from `args`: own `args` (via `static key(args)`, else structural hash of `args`) > `` context `args` > `'default'`. See [Inputs](/guide/inputs). | | **structural key** | A deterministic, order-independent JSON hash of `args` used to key instances when no explicit key is given. Throws (dev) if `args` contains a function. See [Inputs](/guide/inputs). | ## Inputs & identity | Term | Definition | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Args** | Typed, **serializable** creation data (`Args` type param). Passed to `init(args)` once and used to key instance identity. Optional in `useBloc` when declared (inherits from `BlocProvider` or defaults to the default key), forbidden when `void`. See [Inputs](/guide/inputs). | | **Deps** | Non-serializable handles (refs, stable callbacks) injected **per consumer** via the `Deps` type param. Merged into `bloc.deps`; **never** key identity. See [Inputs](/guide/inputs). | | **deps (runtime)** | The merged, read-only view of all consumers' dep slices, exposed as `bloc.deps`. Changes fire [onDepsChanged](#onDepsChanged). Note: a `deps` _option_ on `useBloc` is **internal**, not part of the public hook surface. See [Inputs](/guide/inputs). | | **instanceId** | **Never a `useBloc`/`BlocProvider` option, and no longer an instance property.** Instance identity comes entirely from `args` (via `static key` or structural hash). The resolved key is accessible as `bloc.$blac.id`; the `instanceId()` branded-type helper still exists for constructing typed `InstanceId` values. For a per-mount instance use `args: { _id: useId() }` + `static key`. See [Inputs](/guide/inputs). | | **autoInstance** | **Not a current option.** The shipping mechanism for a fresh per-mount instance is a synthetic `args` field — `args: { _id: useId() }` — plus a `static key` selecting it. The names `autoInstance`/`instanceId`-option survive only in stale comments; there is no `isolated`, `autoInstance`, or `instanceId` static prop or hook option. See [Instance management](/core/instance-management). | | **static key** | A class static `key = (args) => string` (settable directly or via `blac({ key })`) that derives the instance key from `args`. See [Inputs](/guide/inputs). | | **keepAlive** | Set via `blac({ keepAlive: true })`; instances of the class are **never** auto-disposed at ref count zero (still disposable via `forceDispose`/`clear`). See [Configuration](/core/configuration). | | **excludeFromDevTools** | Set via `blac({ excludeFromDevTools: true })`; the class is excluded from DevTools tracking. See [Configuration](/core/configuration). | | **blac()** | The decorator/function that sets exactly **one** class-level option: `keepAlive`, `excludeFromDevTools`, `equality`, or `key`. See [Configuration](/core/configuration). | ## React binding | Term | Definition | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **useBloc** | The React hook: `useBloc(BlocClass, options?)` returns `[state, bloc, ref]`, acquiring on mount and releasing on unmount. See [useBloc](/react/use-bloc). | | **select** | The `useBloc` option to **opt out** of auto-tracking: `select: (state, bloc) => unknown[]` re-renders only when the returned array changes per-index. Must be referentially stable. See [Dependency tracking](/react/dependency-tracking). | | **auto-tracking** | The default re-render strategy: when `select` is omitted, BlaC records which state leaves a render reads and re-renders only when one of them changes. Also called _dependency tracking_ (the feature/page name). See [Dependency tracking](/react/dependency-tracking). | | **tracked proxy** | The recording `Proxy` wrapper (`trackRender`) placed around `state` during render; reads on it log leaf paths used for auto-tracking. There is **no `@tracked` decorator** — tracking is automatic. See [Tracked](/core/tracked). | | **per-consumer tracker** | Each `useBloc` call gets its **own** proxy + recorded path set, so re-renders stay isolated between components reading the same instance. See [Dependency tracking](/react/dependency-tracking). | | **BlocProvider** | A React component that supplies default `args` to descendant `useBloc` calls that omit their own. A call passing its own `args` still wins. See [useBloc](/react/use-bloc). | | **ref (tuple element)** | The third element of the `useBloc` tuple (`ComponentRef`); an advanced-use ref object rarely needed in app code. See [useBloc](/react/use-bloc). | ## Lifecycle & hydration | Term | Definition | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **system event** | A bloc lifecycle signal subscribed via `onSystemEvent(event, handler)`: one of `'stateChanged'`, `'dispose'`, or `'hydrationChanged'`. See [System events](/core/system-events). | | **dispose** | Tearing down an instance: cancels hydration, fires the `'dispose'` system event, clears listeners, and removes it from the registry. Idempotent. See [System events](/core/system-events). | | **hydration** | Restoring previously persisted state into a bloc on startup, driven via `bloc.$blac.hydration` (`begin()` / `apply()` / `finish()` / `fail()`). Plugin authors drive it through `PluginContext` helpers (`ctx.startHydration`, `ctx.applyHydratedState`, etc.). See [Persistence](/plugins/persistence). | | **hydrationStatus** | The current hydration phase (`'idle' \| 'hydrating' \| 'hydrated' \| 'error'`), accessible as `bloc.$blac.hydration.status`. See [System events](/core/system-events). | | **waitForHydration** | `bloc.$blac.hydration.wait()` returns a promise that resolves once hydration settles (idle/hydrated) or rejects on error. See [Persistence](/plugins/persistence). | | **depend / cross-bloc dependency** | `protected depend(Type, defaultArgs?): DepHandle` records a dependency on another bloc and returns a **DepHandle**. Call `.track(options?)` to get `[state, instance]` (auto-subscribes in React render) or `.untracked(options?)` to get the instance with no subscription. No ref is taken. See [Bloc communication](/core/bloc-communication). | | **onDepsChanged** | `protected onDepsChanged(next, prev)` — an override hook that fires whenever the merged per-consumer [deps](#deps-runtime) view changes (and once on dispose with all keys cleared). See [Inputs](/guide/inputs). | ## Plugins & observation | Term | Definition | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **watch** | `watch(blocOrRef, callback)` runs a callback once immediately and again on every change of the watched bloc(s), outside React. Return `watch.STOP` (or call the returned fn) to stop. See [watch](/core/watch). | | **instance() (helper)** | `instance(BlocClass, args)` builds a `BlocRef` so `watch` targets the instance keyed by those args rather than the default one. See [watch](/core/watch). | | **subscribe** | `bloc.subscribe(interest, cb)` — a path-scoped subscription that passes through to `bloc.channel.subscribe`. Prefer `watch` (non-React) or `useBloc` (React) for whole-state observation. See [watch](/core/watch). | | **plugin** | An observer (`BlacPlugin`) that hooks into container lifecycle (`onCreated`, `onStateChange`, `onDestroyed`, `onHydrationChange`, …) across all instances. See [Plugins](/core/plugins). | | **PluginManager** | The singleton (via `getPluginManager()`) that installs, uninstalls, and tracks plugins, gating each by `enabled` and `environment`. See [Plugins](/core/plugins). | | **PluginContext** | The per-dispatch context object passed to plugin hooks; exposes the focal `container`, metadata, state, hydration controls, and registry queries. See [Plugins](/core/plugins). | | **interner** | The per-class `PathInterner` that maps state property paths to integer ids, making path comparison cheap. You rarely touch it directly. See [Tracked](/core/tracked). | | **PathSet** | The set of changed paths marked during a flush — either a `Set` or the `ALL_PATHS` sentinel that means "everything changed." See [Tracked](/core/tracked). | ## Disambiguation These clusters cause the most confusion. Keep them straight:
select vs deps - **`select`** — a `useBloc` option that **narrows re-renders**: `(state, bloc) => unknown[]`. Opting in disables auto-tracking for that consumer. See [Dependency tracking](/react/dependency-tracking). - **`deps`** — the **non-serializable handle lane** (`Deps` type param + `bloc.deps` view). Nothing to do with re-rendering. See [Inputs](/guide/inputs).
StateContainer vs Cubit vs bloc vs instance - **`StateContainer`** — the abstract base class (you rarely extend it). - **`Cubit`** — the concrete class you extend; structurally identical to `StateContainer`. - **bloc** — informal word for _any_ container instance. There is **no `Bloc` class**. - **instance** — one concrete live object of a class, shared by ref count under an instance key. See [Concepts](/guide/concepts).
auto-tracking vs dependency tracking vs proxy tracking All three name the **same** mechanism: the render-time recording proxy that logs which state leaves a component reads, so re-renders fire only on relevant changes. The docs use **auto-tracking** for the behavior and **dependency tracking** for the feature/page. See [Dependency tracking](/react/dependency-tracking).
:::caution[No `Bloc` class, no `@tracked`, no `autoTrack`/`autoInstance`/`instanceId` options] BlaC has **no `Bloc` class** (only `StateContainer` and `Cubit`), **no `@tracked` decorator**, and **no `autoTrack`, `autoInstance`, or `instanceId` hook options**. Tracking is automatic and unconditional unless you pass `select`; a fresh per-mount instance comes from `args: { _id: useId() }` + `static key`. The instance's own identity is at `bloc.$blac.id` (not a top-level `instanceId` property). If a doc or comment mentions top-level `instanceId`/`name`/`isDisposed` on the bloc instance, it is stale — those are now under `$blac`. ::: ## See also - [Concepts](/guide/concepts) — the quick tour of the model behind these terms - [Mental model](/guide/mental-model) — the deep "why it works this way" - [Inputs](/guide/inputs) — `args`, `deps`, and instance identity in full - [useBloc](/react/use-bloc) — the canonical `useBloc` options and identity precedence --- # 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), and for the judgment of _which lane to reach for_ see [Best Practices](/guide/best-practices). ## 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 void this.loadUser(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 [Glossary](/guide/glossary) 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 the bloc's deps methods from a **mount effect** (post-commit, as the rules require). Import the methods from `@blac/core`: ::: ```tsx import { useEffect, useId, useRef } from 'react'; import { APPLY_DEPS, REMOVE_DEPS_OWNER } from '@blac/core'; import { useBloc } from '@blac/react'; function UploadButton({ endpoint }: { endpoint: string }) { const inputRef = useRef(null); const ownerId = useId(); // identifies THIS consumer's slice // same endpoint → same instance, regardless of which ref is passed const [state, cubit] = useBloc(FileUploadCubit, { args: { endpoint } }); useEffect(() => { cubit[APPLY_DEPS](ownerId, { inputRef }); // contribute this consumer's slice return () => cubit[REMOVE_DEPS_OWNER](ownerId); // withdraw it on unmount }, [cubit, inputRef, ownerId]); return ; } ``` `ownerId` (a `useId()` value, stable per mount) tags the slice so the engine knows which consumer wrote it and can withdraw exactly that slice on unmount.
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. The effect runs post-commit; its cleanup runs before the consumer's `useBloc` releases its ref, so the slice is withdrawn while the bloc is still alive. `APPLY_DEPS`/`REMOVE_DEPS_OWNER` are marked `@internal` today (a friendlier wrapper may land later), but this is the supported path.
### 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 cubit[APPLY_DEPS](ownerIdA, { inputRef }); // Component B owns onSubmit cubit[APPLY_DEPS](ownerIdB, { 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 from an effect; 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 - [Best Practices](/guide/best-practices) — _which_ lane to choose, and the judgment behind it - [Mental Model](/guide/mental-model) — _why_ instances are shared and ref-counted - [useBloc](/react/use-bloc) — the complete option list and identity precedence (canonical) - [Glossary](/guide/glossary) — definitions of `args`, `deps`, `static key` - [Patterns & Recipes](/guide/patterns) — concrete copy-paste recipes - [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/structural` Concepts](/dirtytalk/structural/concepts). 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 coarsens.** `.map` / `.find` / `for..of` on an array record the _entry_ path (`items`) but not per-index paths (`items.0`) or `items.length`. Array methods are bound to the raw target so their internal reads bypass the proxy. Direct index access still records the leaf: `value.items[2]` records `items.2`. - **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 N intersections** 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`). That one walk produces the dirty set; then each consumer wakes via a cheap set intersection (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 single-consumer skip] The skeleton diff only pays off with 2+ consumers to share the walk across. With 0 or 1 registered consumers, `emit`/`update` skip the diff entirely and mark `ALL_PATHS` — the sole consumer (if any) wakes regardless. Fine-grained isolation begins at two consumers. `patch` never takes this shortcut; it always value-diffs the patch shape, so raw `subscribe` callers wake correctly too. ::: ### How React ties in `useBloc` (and the standalone `useStructural`) needs **no selector** because the JSX _is_ the interest declaration. Per render it: - derives a stable consumer id from `useId()` and forces re-renders with a `useReducer` tick (no virtual DOM — React reconciles); - calls `trackRender(container.state, container.interner)`, renders against the proxy, and stashes the now-populated `paths` in a ref; - registers that set in a layout effect **after** render (never in the render body — at that point `paths` is still empty and would freeze an empty skeleton, silently dropping wakeups); - subscribes `() => pathRef.current` to the channel, so the channel re-evaluates the live interest on every flush and 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 (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, each 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/structural` Concepts](/dirtytalk/structural/concepts) — 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/ :::caution[Beta / pre-release] BlaC v2 is in pre-release (beta). While in beta, **breaking API changes may ship in patch releases** without a major version bump — pin exact versions and check the changelog before upgrading. Strict semver resumes once v2 is officially out of beta. ::: BlaC (Business Logic Components) is a TypeScript state management library for React. It separates business logic into class-based state containers — [Cubits](/guide/glossary#core-model) — 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`). The honest, side-by-side comparison — including where those other libraries are the better fit — lives on the [Comparison](/guide/comparison) page. ::: ## 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 - [Patterns & Recipes](/guide/patterns) — Common patterns for real apps - [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 - [Glossary](/guide/glossary) — one-line definitions for every term used here --- # 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/structural/concepts) engine that BlaC's `StateContainer` extends. If you want the path-interning, skeleton, and proxy-recorder internals in full — including the exact recording rules for arrays, `Map`/`Set`/`Date`, and iteration — that page is the source of truth. 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. It must be referentially stable (wrap it in `useCallback`) — a fresh function each render re-keys the subscription. 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/comparison) 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 - [Structural: Concepts](/dirtytalk/structural/concepts) — the path-tracking engine BlaC is built on --- # Patterns & Recipes > Concrete, copy-pasteable patterns for async operations, named instances, cross-bloc communication, plugins, keep-alive, and getter-based computed values. Source: /guide/patterns/ Common patterns for structuring BlaC applications. Each pattern is drawn from real examples in the codebase. :::note[Recipes vs. principles] This page is a **cookbook**: concrete, copy-pasteable solutions to recurring problems. For the _principles_ behind these choices — when to reach for one approach over another, and the smells to avoid — see [Best Practices](/guide/best-practices). Rule of thumb: come here when you know _what_ you want to build; go there when you're deciding _whether_ an approach is sound. ::: ## Async operations ### Loading / error / success Model async state explicitly. Use a request ID to handle race conditions when multiple requests overlap. ```ts interface FeedState { articles: Article[]; status: 'idle' | 'loading' | 'error' | 'success'; error: string | null; } class FeedCubit extends Cubit { private requestId = 0; constructor() { super({ articles: [], status: 'idle', error: null }); } loadArticles = async (category: string) => { const id = ++this.requestId; this.patch({ status: 'loading', error: null }); try { const articles = await api.fetchArticles(category); // Ignore stale responses if (id !== this.requestId) return; this.emit({ articles, status: 'success', error: null }); } catch (e) { if (id !== this.requestId) return; this.patch({ status: 'error', error: String(e) }); } }; } ``` The `requestId` pattern is simpler than AbortController for most cases. Each new call invalidates previous in-flight responses. Modelling async state as an explicit `status` enum (rather than scattered `isLoading`/`error` booleans) is a principle covered in [Best Practices](/guide/best-practices). ### Hydration-aware loading When using the [persistence plugin](/plugins/persistence), state may arrive asynchronously from IndexedDB. Override `init` and `await this.$blac.hydration.wait()` before making API calls, so you don't overwrite restored values with a stale fetch: ```ts class SettingsCubit extends Cubit { constructor() { super({ theme: 'light', locale: 'en' }); } protected override async init() { await this.$blac.hydration.wait(); // Now this.state has restored values from IndexedDB await this.refreshFromServer(); } } ``` `init` is the framework's once-per-instance setup hook — a protected method called after construction, before the first state snapshot, with the bloc's `args`. `$blac.hydration.wait()` resolves once the persistence plugin finishes restoring (or immediately if nothing is persisted); see [system events](/core/system-events) for the `hydrationChanged` event that drives it. ## Action-only components Components that only trigger actions and never display state do not need a selector. Just avoid reading from `state`: auto-tracking records an empty dependency set, so state changes have nothing to wake. ```tsx function QuickAdd() { const [, todo] = useBloc(TodoCubit); return ; } ``` This component renders once and never re-renders, regardless of state changes. :::tip[Use `select` for derived dependencies] Reach for `select` when a computed value or explicit dependency list should drive re-renders. Do not use it just to make an action-only component quiet. See [Performance](/react/performance) for the full reader/writer split rationale and [useBloc](/react/use-bloc) for `select` semantics. ::: ## Named instances When you need multiple independent instances of the same Cubit class, make the distinguishing name an `args` field and key on it with `static key`. Each name gets its own instance with its own state and lifecycle. ```tsx class FormCubit extends Cubit { static key = (a: FormCubit['args']) => a.section; } function FormPage() { return ( <> ); } function FormSection({ section }: { section: string }) { const [state, form] = useBloc(FormCubit, { args: { section } }); // Each section has independent state return ( form.setEmail(e.target.value)} /> ); } ``` Named instances are ref-counted independently. When all components using `"billing"` unmount, that instance is disposed while `"shipping"` stays alive. The name (`"billing"` / `"shipping"`) is just as much `args` data as a `userId` or `docId` would be — there is no separate key channel. See [Passing Inputs](/guide/inputs) for the full identity model. ## Persisting state outside React Use [`watch`](/core/watch) to observe state changes from non-React code — saving to localStorage, syncing to a server, or feeding analytics. The callback receives the **bloc instance** (read its state via `bloc.state`), fires once immediately, then on every change: ```ts import { watch } from '@blac/core'; watch(TodoCubit, (bloc) => { localStorage.setItem('todos', JSON.stringify(bloc.state.items)); }); ``` `watch` resolves the instance via `ensure` (no ref count, so it does not keep the bloc alive) and observes all of its state. To stop watching, either call the returned function or return `watch.STOP` from the callback: ```ts const stop = watch(AuthCubit, (bloc) => { if (bloc.state.user) { analytics.identify(bloc.state.user.id); return watch.STOP; // one-shot } }); ``` ## Cross-bloc communication The recipes below are the common shapes; [Bloc Communication](/core/bloc-communication) is the full reference for `depend()` and its lifecycle caveats, and [Best Practices](/guide/best-practices) covers when cross-bloc coupling is a smell versus a clean dependency. ### Reading state from another bloc Use `depend()` to declare a dependency. It returns a handle that resolves the instance from the registry on demand — `.untracked()` for a plain read. ```ts class CartCubit extends Cubit { private shipping = this.depend(ShippingCubit); get total() { const subtotal = this.state.items.reduce((sum, i) => sum + i.price, 0); return subtotal + this.shipping.untracked().state.rate; } } ``` ### Auto-tracking a dependency with `.track()` A plain `.untracked()` read (`this.shipping.untracked().state.rate`) is _not_ reactive on its own — a component reading `cart.total` only re-renders on shipping changes if it _also_ calls `useBloc(ShippingCubit)`. Calling `.track()` on the handle removes that ceremony: the component reading the getter auto-subscribes to the dependency too. ```ts class CartCubit extends Cubit { private shipping = this.depend(ShippingCubit); get total() { const subtotal = this.state.items.reduce((sum, i) => sum + i.price, 0); const [shippingState] = this.shipping.track(); // opt in to cross-bloc reactivity return subtotal + shippingState.rate; } } ``` ```tsx // The component subscribes to CartCubit only — yet re-renders when shipping changes too. function CartTotal() { const [, cart] = useBloc(CartCubit); return ${cart.total.toFixed(2)}; } ``` `.track()` is render-aware (outside render it degrades to live values, no subscription), tracks the dependency's own getters transitively, and supports conditional and mutual dependencies. See [Auto-tracking with `.track()`](/core/bloc-communication#auto-tracking-with-track) for the full reference. ### Triggering side effects across blocs Call methods on dependencies to coordinate behavior: ```ts class ChannelCubit extends Cubit { private notifications = this.depend(NotificationCubit); receiveMessage = (message: Message) => { this.patch({ messages: [...this.state.messages, message] }); this.notifications.untracked().incrementUnread(this.state.channelId); }; } ``` ### Lazy instance creation When a dependency might not exist yet, use `borrowSafe` to check and `acquire` to create on demand: ```ts import { borrowSafe, acquire } from '@blac/core'; class ChannelCubit extends Cubit { private ensureUserCubit(userId: string) { const { error } = borrowSafe(UserCubit, { args: { userId } }); if (!error) return; acquire(UserCubit, { args: { userId } }); // keyed by userId; init seeds state } receiveMessage = (message: Message) => { this.ensureUserCubit(message.userId); // ... }; } ``` ## Saving state on disposal Use `onSystemEvent('dispose')` to persist data when an instance is cleaned up: ```ts class ChannelCubit extends Cubit { constructor() { super({ channel: null, messages: [] }); this.onSystemEvent('dispose', () => { if (this.state.channel) { persistenceService.save(this.state.channel.id, this.state.messages); } }); } } ``` ## Custom plugins Plugins observe lifecycle events across all state containers. Use them for cross-cutting concerns like analytics, logging, or monitoring. Every hook takes the `PluginContext` first; the bloc the event is about is `ctx.container`, and `prev`/`next` in `onStateChange` are the **state objects**, not the bloc: ```ts const analyticsPlugin: BlacPlugin = { name: 'analytics', version: '1.0.0', onCreated(ctx) { analytics.track('bloc_created', { name: ctx.container?.name }); }, onStateChange(ctx, prev, next, paths) { analytics.track('state_changed', { name: ctx.container?.name, from: prev, to: next, }); }, onDestroyed(ctx) { analytics.track('bloc_disposed', { name: ctx.container?.name }); }, }; getPluginManager().install(analyticsPlugin); ``` See [Plugin Authoring](/core/plugins) for the full hook reference, the `paths` parameter, and the `environment` option (skip a plugin outside `development`, for example). ## Keep-alive instances For app-wide singletons that should never be disposed (auth, theme, feature flags), use `keepAlive`: ```ts @blac({ keepAlive: true }) class ThemeCubit extends Cubit<{ mode: 'light' | 'dark' }> { constructor() { super({ mode: 'light' }); } toggle = () => { this.patch({ mode: this.state.mode === 'light' ? 'dark' : 'light' }); }; } ``` The instance is created on first use and stays alive for the entire app session, even if all components using it unmount. `keepAlive` only disables the _auto-dispose at refcount zero_; the instance is still disposable via `clear()` or a forced release. See [Configuration](/core/configuration) for the option and [Instance Management](/core/instance-management) for how ref-counting and auto-dispose interact. ## Per-component private instances When a component needs its _own_ instance — never shared with siblings, disposed when it unmounts — add a per-mount unique value to `args` and key on it with `static key`. `useId()` is the idiomatic source of a stable-per-mount value: ```tsx import { useId } from 'react'; class UploadCubit extends Cubit { static key = (a: UploadCubit['args']) => a._id; } function UploadWidget() { const _id = useId(); // unique per mount, stable across this mount's renders const [state, upload] = useBloc(UploadCubit, { args: { _id } }); return ; } ``` Two `` siblings get two independent instances; each is disposed on its own unmount. This is the supported replacement for the removed `autoInstance`/`instanceId` options. Any real identity data goes in the same `args` object alongside `_id` — see [Passing Inputs](/guide/inputs). ## Getter-based computed values Define getters on your Cubit for derived state instead of storing the computed value in state. In React render, getters auto-track: reading `todo.activeCount` records the `this.state` paths the getter touches through the returned bloc proxy. Use `select` when you want the getter's return value, rather than its source paths, to decide re-renders. See [Performance: getters as computed properties](/react/performance#pattern-getters-as-computed-properties). ```ts class TodoCubit extends Cubit { get activeCount() { return this.state.items.filter((t) => !t.done).length; } get completedCount() { return this.state.items.filter((t) => t.done).length; } get filteredItems() { if (this.state.filter === 'all') return this.state.items; const isDone = this.state.filter === 'done'; return this.state.items.filter((t) => t.done === isDone); } } ``` ```tsx function TodoStats() { const [, todo] = useBloc(TodoCubit); return ( {todo.activeCount} active, {todo.completedCount} done ); } // Or use `select` so the getter results gate re-renders: function TodoStatsSelected() { const [, todo] = useBloc(TodoCubit, { select: (_, bloc) => [bloc.activeCount, bloc.completedCount], }); return ( {todo.activeCount} active, {todo.completedCount} done ); } ``` :::tip[Derive, don't duplicate] Storing `activeCount` _in state_ alongside `items` means every mutation has to remember to update both — they drift. A getter computes on read and can never go stale. Best Practices treats "derive with getters, don't store computed values" as a core principle. ::: ## More recipes The patterns above cover the core primitives. The recipes below handle common higher-level scenarios — each is a self-contained page with a twoslash-checked Cubit block and a plain TSX component example: - [Optimistic Update](/guide/recipes/optimistic-update) — apply a mutation immediately, roll back on error - [Debounce](/guide/recipes/debounce) — collapse rapid input into a single deferred action - [Undo / Redo](/guide/recipes/undo-redo) — past/future state stacks with a history cap - [Pagination](/guide/recipes/pagination) — offset/page and cursor-based infinite scroll - [WebSocket Subscription](/guide/recipes/websocket) — persistent server-pushed connections - [Form Validation](/guide/recipes/form-validation) — touched map, getter-derived errors, async submit - [Reset to Initial State](/guide/recipes/reset-to-initial) — one-call full or partial reset ## See also - [Best Practices](/guide/best-practices) — the principles behind these recipes - [Performance](/react/performance) — reader/writer splitting and proxy-cost mechanics - [Bloc Communication](/core/bloc-communication) — full `depend()` reference and lifecycle caveats - [Instance Management](/core/instance-management) — ref-counting, `keepAlive`, and auto-dispose - [Passing Inputs](/guide/inputs) — `args`, `deps`, and per-mount instances - [Cubit](/core/cubit) — `init`, `emit`/`patch`/`update`, and getters --- # Debounce > Collapse rapid user input into one deferred Cubit action by storing the debounce timer as a private field and clearing it on dispose. Source: /guide/recipes/debounce/ **Use when:** you want to collapse rapid user input (search-as-you-type, resize, window scroll) into one deferred action to avoid hammering the server or doing expensive work on every keystroke. **Don't use when:** the action must fire immediately on the first event and you only want to throttle subsequent ones — use throttle instead. ## Pattern Store the debounce timer as a private field on the Cubit and cancel it before scheduling a new one. No external debounce library is needed. ```ts twoslash import { Cubit } from '@blac/core'; interface SearchResult { id: string; title: string; } interface SearchState { query: string; results: SearchResult[]; status: 'idle' | 'loading' | 'success' | 'error'; error: string | null; } declare const api: { search(q: string): Promise; }; // ---cut--- class SearchCubit extends Cubit { // Timer handle for the pending debounced call. private debounceTimer: ReturnType | null = null; private requestId = 0; constructor() { super({ query: '', results: [], status: 'idle', error: null }); // Cancel any in-flight timer when the instance is disposed. this.onSystemEvent('dispose', () => { if (this.debounceTimer !== null) clearTimeout(this.debounceTimer); }); } setQuery = (query: string) => { // Update the input field immediately so the UI feels responsive. this.patch({ query, status: 'idle' }); // Cancel the previous pending search. if (this.debounceTimer !== null) clearTimeout(this.debounceTimer); if (!query.trim()) { this.patch({ results: [], status: 'idle' }); return; } // Schedule the actual fetch after 300 ms of silence. this.debounceTimer = setTimeout(() => { void this.fetchResults(query); }, 300); }; private fetchResults = async (query: string) => { const reqId = ++this.requestId; this.patch({ status: 'loading', error: null }); try { const results = await api.search(query); if (reqId !== this.requestId) return; // superseded by a newer call this.patch({ results, status: 'success' }); } catch (e) { if (reqId !== this.requestId) return; this.patch({ results: [], status: 'error', error: String(e) }); } }; } ``` ```tsx function SearchBox() { const [state, search] = useBloc(SearchCubit); return (
search.setQuery(e.target.value)} placeholder="Search…" /> {state.status === 'loading' &&

Searching…

}
    {state.results.map((r) => (
  • {r.title}
  • ))}
); } ``` :::caution[Cancel on dispose] If the debounce timer fires after the Cubit is disposed, it will attempt to `emit` on a dead container and throw. Always clear the timer in the `dispose` system event, as shown above. ::: :::tip[Combine with the request-id guard] The timer collapses keystrokes, but two timers can still fire close together. The `requestId` guard (shown in `fetchResults`) ensures only the last response is applied. See [Async](/guide/async#the-request-id-guard) for the full pattern. ::: ## See also - [Async](/guide/async) — request-id guard and cancellation with `AbortController` - [System Events](/core/system-events) — `dispose` event for cleanup --- # Form Validation > Model form fields and a touched map in Cubit state, derive validation errors as a getter, and show errors only for touched fields. Source: /guide/recipes/form-validation/ **Use when:** a form has non-trivial cross-field validation rules, async field-level checks (e.g. "username already taken"), or multi-step workflows that share state across steps. **Don't use when:** a form is a simple uncontrolled HTML form or a library like React Hook Form already owns it — adding a Cubit on top creates two sources of truth. ## Pattern Keep field values and a `touched` map in state. Derive validation errors as a getter — they recompute on every read and can never go stale. Show errors only for touched fields so the initial load is clean. ```ts twoslash import { Cubit } from '@blac/core'; interface RegisterState { email: string; password: string; confirmPassword: string; touched: Partial>; submitStatus: 'idle' | 'loading' | 'success' | 'error'; submitError: string | null; } declare const api: { register(email: string, password: string): Promise; }; // ---cut--- class RegisterCubit extends Cubit { constructor() { super({ email: '', password: '', confirmPassword: '', touched: {}, submitStatus: 'idle', submitError: null, }); } // ── field setters ────────────────────────────────────────────────────── setEmail = (email: string) => this.patch({ email }); setPassword = (password: string) => this.patch({ password }); setConfirmPassword = (confirmPassword: string) => this.patch({ confirmPassword }); /** Mark a field as interacted with so its error becomes visible. */ touchField = (field: keyof RegisterState['touched']) => { this.patch({ touched: { ...this.state.touched, [field]: true } }); }; // ── derived validation ───────────────────────────────────────────────── /** All errors keyed by field name (always computed, never stored). */ get errors(): Partial< Record<'email' | 'password' | 'confirmPassword', string> > { const { email, password, confirmPassword } = this.state; const errors: Partial< Record<'email' | 'password' | 'confirmPassword', string> > = {}; if (!email.includes('@')) errors.email = 'Enter a valid email address.'; if (password.length < 8) errors.password = 'Password must be ≥ 8 characters.'; if (confirmPassword !== password) errors.confirmPassword = 'Passwords do not match.'; return errors; } get isValid() { return Object.keys(this.errors).length === 0; } // ── submit ───────────────────────────────────────────────────────────── submit = async () => { // Touch all fields so every error surfaces on an attempted submit. this.patch({ touched: { email: true, password: true, confirmPassword: true }, }); if (!this.isValid) return; this.patch({ submitStatus: 'loading', submitError: null }); try { await api.register(this.state.email, this.state.password); this.patch({ submitStatus: 'success' }); } catch (e) { // ⚠️ Do NOT include the raw password in any error log or analytics event. this.patch({ submitStatus: 'error', submitError: String(e) }); } }; } ``` ```tsx function RegisterForm() { const [state, form] = useBloc(RegisterCubit); const errors = form.errors; const { touched } = state; return (
{ e.preventDefault(); void form.submit(); }} > {state.submitError &&

{state.submitError}

}
); } ``` :::tip[Errors as a getter, not state] Storing computed errors in state means every field change requires updating two places — they will eventually drift. A getter recalculates on read and is always in sync. Reading `form.errors` in render auto-tracks the state fields the getter touches; use `select` only when the getter result should be the explicit re-render boundary. See [Dependency Tracking](/react/dependency-tracking). ::: :::caution[PII in state] Form state (email addresses, passwords, health data) is observable by plugins and `watch` callbacks. If you install analytics or logging plugins, ensure they scrub sensitive field names before shipping to a sink. Consider `excludeFromDevTools` on forms that handle passwords: ```ts twoslash import { blac, Cubit } from '@blac/core'; @blac({ excludeFromDevTools: true }) class SecureFormCubit extends Cubit<{ password: string }> { constructor() { super({ password: '' }); } } ``` ::: ## Async field-level validation For async checks (username availability, coupon validation), pattern them like a debounced async action — see [Debounce](/guide/recipes/debounce) — and merge the result into the errors object or a separate `asyncErrors` field. ## See also - [Cubit](/core/cubit) — `patch`, `emit`, getters - [Patterns](/guide/patterns) — named instances for billing/shipping form sections - [Debounce](/guide/recipes/debounce) — async field-level validation --- # Optimistic Update > Apply a mutation to local Cubit state immediately, snapshot for rollback, fire the request, and restore the previous state on failure. Source: /guide/recipes/optimistic-update/ **Use when:** you want the UI to reflect a mutation immediately, before the server confirms it — then reconcile or roll back when the response arrives. **Don't use when:** the action is destructive and hard to undo, or the server response carries data that can't be predicted client-side. ## Pattern Apply the change to local state immediately, record enough context to roll back, fire the request, and on failure restore the previous state (or re-fetch the authoritative list). ```ts twoslash import { Cubit } from '@blac/core'; interface Todo { id: string; text: string; done: boolean; } interface TodoState { items: Todo[]; error: string | null; } declare const api: { markDone(id: string): Promise; }; // ---cut--- class TodoCubit extends Cubit { constructor() { super({ items: [], error: null }); } toggleDone = async (id: string) => { // 1. Snapshot for rollback. const previous = this.state.items; // 2. Apply optimistically — update only the matching item. this.patch({ items: previous.map((t) => (t.id === id ? { ...t, done: !t.done } : t)), error: null, }); try { await api.markDone(id); // Server confirmed — nothing more to do. } catch (e) { // 3. Roll back to the snapshot on failure. // ⚠️ Do NOT ship the full state to an analytics sink before // confirming — a rolled-back item would send false telemetry. this.patch({ items: previous, error: String(e) }); } }; } ``` ```tsx // Hoisted to module level so the reference is stable across renders. // This component only calls actions and never reads state, so an empty // selector suppresses all re-renders. const selectNothing = () => []; function TodoItem({ id, text, done, }: { id: string; text: string; done: boolean; }) { const [, todo] = useBloc(TodoCubit, { select: selectNothing }); return (
  • todo.toggleDone(id)} /> {text}
  • ); } ``` :::tip[Snapshot granularity] Snapshot only the slice you're mutating (`this.state.items`), not the whole state — this avoids accidentally rolling back unrelated fields that changed between the optimistic apply and the server response. ::: :::caution[Race condition] If `toggleDone` is called twice rapidly, both calls capture the _same_ `previous`. Use the [request-id guard](/guide/async#the-request-id-guard) or disable the trigger while a request is in-flight to prevent the second rollback from clobbering the first response. ::: ## See also - [Async](/guide/async) — request-id guard for concurrent requests - [Cubit](/core/cubit) — `patch` deep-merges; `emit` replaces wholesale --- # Pagination > Page through a large server-side list in a Cubit — offset/page-based and cursor-based (infinite scroll) variants with per-page loading state. Source: /guide/recipes/pagination/ **Use when:** you need to page through a large server-side list — cursor-based or offset/page-based — with loading state per page and optional prefetch. **Don't use when:** the full list fits in memory comfortably; just load everything once and slice client-side. ## Offset / page-based The most common shape: a `page` number plus a `totalPages` count returned by the server. ```ts twoslash import { Cubit } from '@blac/core'; interface Article { id: string; title: string; } interface PageResult { items: Article[]; totalPages: number; } declare const api: { listArticles(page: number, perPage: number): Promise; }; // ---cut--- interface PaginationState { items: Article[]; page: number; totalPages: number; status: 'idle' | 'loading' | 'success' | 'error'; error: string | null; } class ArticleListCubit extends Cubit { private readonly perPage = 20; private requestId = 0; constructor() { super({ items: [], page: 1, totalPages: 1, status: 'idle', error: null }); } protected override async init() { await this.loadPage(1); } loadPage = async (page: number) => { const reqId = ++this.requestId; this.patch({ status: 'loading', error: null }); try { const { items, totalPages } = await api.listArticles(page, this.perPage); if (reqId !== this.requestId) return; this.patch({ items, page, totalPages, status: 'success' }); } catch (e) { if (reqId !== this.requestId) return; this.patch({ status: 'error', error: String(e) }); } }; nextPage = () => { if (this.state.page < this.state.totalPages) { void this.loadPage(this.state.page + 1); } }; prevPage = () => { if (this.state.page > 1) { void this.loadPage(this.state.page - 1); } }; get hasNext() { return this.state.page < this.state.totalPages; } get hasPrev() { return this.state.page > 1; } } ``` ```tsx function ArticleList() { const [state, list] = useBloc(ArticleListCubit, { select: (s, bloc) => [ s.items, s.page, s.totalPages, s.status, bloc.hasNext, bloc.hasPrev, ], }); if (state.status === 'loading') return

    Loading…

    ; if (state.status === 'error') return

    Error: {state.error}

    ; return (
      {state.items.map((a) => (
    • {a.title}
    • ))}
    Page {state.page} / {state.totalPages}
    ); } ``` ## Cursor-based (infinite scroll / "load more") Cursor pagination keeps appending items rather than replacing them. The server returns an opaque `nextCursor`; a `null` cursor signals the end. ```ts twoslash import { Cubit } from '@blac/core'; interface Post { id: string; body: string; } declare const api: { fetchPosts( cursor: string | null, ): Promise<{ posts: Post[]; nextCursor: string | null }>; }; // ---cut--- interface FeedState { posts: Post[]; cursor: string | null; // null = no more pages status: 'idle' | 'loading' | 'success' | 'error'; error: string | null; } class FeedCubit extends Cubit { private requestId = 0; constructor() { super({ posts: [], cursor: null, status: 'idle', error: null }); } protected override async init() { await this.loadMore(); } loadMore = async () => { // Guard: do nothing if already loading or no more pages. if ( this.state.status === 'loading' || (this.state.status === 'success' && this.state.cursor === null) ) { return; } const reqId = ++this.requestId; this.patch({ status: 'loading', error: null }); try { const { posts, nextCursor } = await api.fetchPosts(this.state.cursor); if (reqId !== this.requestId) return; // Append — do not replace the existing list. this.patch({ posts: [...this.state.posts, ...posts], cursor: nextCursor, status: 'success', }); } catch (e) { if (reqId !== this.requestId) return; this.patch({ status: 'error', error: String(e) }); } }; get hasMore() { return this.state.cursor !== null; } } ``` ```tsx function Feed() { const [state, feed] = useBloc(FeedCubit, { select: (s, bloc) => [s.posts, s.status, bloc.hasMore], }); return (
    {state.posts.map((p) => (
    {p.body}
    ))} {feed.hasMore && ( )}
    ); } ``` :::caution[Cursor leakage] Never log or expose raw cursor tokens to the UI — they may embed user identity or internal shard keys. Treat `cursor` as an opaque internal value. ::: :::tip[Reset on filter change] When the user changes a filter, reset the entire list: `this.emit({ posts: [], cursor: null, status: 'idle', error: null })` then call `loadMore()`. Without the reset, old posts from the previous filter bleed into the new page. ::: ## See also - [Async](/guide/async) — request-id guard and the loadable surface - [Cubit](/core/cubit) — `patch` deep-merges, `emit` replaces wholesale --- # Reset to Initial State > Restore every field to its defaults in one atomic emit by storing the initial state in the constructor or seeding it from args in init. Source: /guide/recipes/reset-to-initial/ **Use when:** a form, wizard, or filter panel needs a "Clear" or "Cancel" button that restores every field to its defaults in one action. **Don't use when:** the reset is partial (only some fields) — prefer `patch` with the specific defaults rather than a full state replace. ## Pattern Store the initial state once in the constructor and call `emit` with it to restore it in one atomic update. ```ts twoslash import { Cubit } from '@blac/core'; interface FilterState { query: string; category: string; minPrice: number; maxPrice: number; sortBy: 'price' | 'rating' | 'newest'; } // ---cut--- const DEFAULT_FILTER: FilterState = { query: '', category: 'all', minPrice: 0, maxPrice: 10_000, sortBy: 'newest', }; class FilterCubit extends Cubit { // Keep the initial state immutable — emit makes a reference check so we // must not mutate this object in place. private readonly initial: FilterState; constructor(initial: FilterState = DEFAULT_FILTER) { super(initial); this.initial = initial; } setQuery = (query: string) => this.patch({ query }); setCategory = (category: string) => this.patch({ category }); setMinPrice = (minPrice: number) => this.patch({ minPrice }); setMaxPrice = (maxPrice: number) => this.patch({ maxPrice }); setSortBy = (sortBy: FilterState['sortBy']) => this.patch({ sortBy }); /** Restore all fields to their initial values in one emit. */ reset = () => { // emit replaces the whole state; patch would also work but emit is // clearer in intent — we're not updating a subset, we're replacing all. this.emit({ ...this.initial }); // ^^^^^^^^^^^^^^^ // Spread produces a new object reference so the equality check can // detect a change even if the current state already matches the defaults. }; get isDirty() { const s = this.state; return ( s.query !== this.initial.query || s.category !== this.initial.category || s.minPrice !== this.initial.minPrice || s.maxPrice !== this.initial.maxPrice || s.sortBy !== this.initial.sortBy ); } } ``` ```tsx function FilterPanel() { const [state, filter] = useBloc(FilterCubit, { select: (s, bloc) => [s.query, s.category, s.sortBy, bloc.isDirty], }); return (
    filter.setQuery(e.target.value)} placeholder="Search…" /> {filter.isDirty && }
    ); } ``` ## Args-seeded initial state When the initial state comes from `args` (for example a per-user default saved on the server), capture it in `init` instead of the constructor: ```ts twoslash import { Cubit } from '@blac/core'; interface FilterState { query: string; category: string; } // ---cut--- class UserFilterCubit extends Cubit { private savedInitial!: FilterState; constructor() { super({ query: '', category: 'all' }); } protected override init(args: FilterState) { // args is the authoritative initial state for this user. this.savedInitial = args; this.emit({ ...args }); } reset = () => { this.emit({ ...this.savedInitial }); }; } ``` :::caution[Spread before emit] `this.emit(this.initial)` passes the _same object reference_ as the current state if nothing has changed yet. `emit` short-circuits on referential equality (`prev === next`), so the reset would be silently skipped. Spread (`{ ...this.initial }`) produces a fresh object, ensuring the emit goes through. ::: :::tip[Partial reset] If you want to reset only specific fields, use `patch` instead of `emit`: ```ts twoslash import { Cubit } from '@blac/core'; interface FilterState { query: string; category: string; sortBy: string; } class FilterCubit extends Cubit { constructor() { super({ query: '', category: 'all', sortBy: 'newest' }); } // ---cut--- resetSearch = () => { // Resets only `query`; keeps `category` and `sortBy` intact. this.patch({ query: '' }); }; // ---cut-after--- } ``` ::: ## See also - [Cubit](/core/cubit) — `emit` replaces state; `patch` deep-merges - [Patterns](/guide/patterns) — named instances for parallel forms --- # Undo / Redo > Implement undo/redo in a Cubit with past and future state stacks, a history cap, and getters for canUndo/canRedo. Source: /guide/recipes/undo-redo/ **Use when:** users need to reverse discrete actions — a text editor, a canvas, a form with destructive bulk edits. **Don't use when:** the state is large and serialization is expensive; consider structural sharing or operation-log approaches instead. ## Pattern Keep a stack of past states and a stack of future states alongside the current one. Each mutation saves the previous state to `past`; undo pops from `past` and pushes to `future`; redo reverses that. ```ts twoslash import { Cubit } from '@blac/core'; // The domain value being edited. interface Note { title: string; body: string; } interface EditorState { note: Note; past: Note[]; // oldest … newest-before-current future: Note[]; // most-recently-undone … oldest-undone } // ---cut--- class EditorCubit extends Cubit { // Hard cap prevents unbounded memory growth. private static readonly MAX_HISTORY = 50; // One instance per note title — args both seed and key the instance. static key = (initial: Note) => initial.title; constructor() { super({ note: { title: '', body: '' }, past: [], future: [] }); } protected init(initial: Note) { this.emit({ note: initial, past: [], future: [] }); } /** Push the current note onto the past stack, then apply `next`. */ private applyChange(next: Note) { const { note, past } = this.state; const trimmed = past.slice(-(EditorCubit.MAX_HISTORY - 1)); this.emit({ note: next, past: [...trimmed, note], future: [] }); // ^^^^^^^^ // ⚠️ Always clear `future` on a new edit — an undo-then-type flow should // not let the user redo the overwritten branch. } setTitle = (title: string) => { this.applyChange({ ...this.state.note, title }); }; setBody = (body: string) => { this.applyChange({ ...this.state.note, body }); }; undo = () => { const { note, past, future } = this.state; if (past.length === 0) return; const previous = past[past.length - 1]; this.emit({ note: previous, past: past.slice(0, -1), future: [note, ...future], }); }; redo = () => { const { note, past, future } = this.state; if (future.length === 0) return; const next = future[0]; this.emit({ note: next, past: [...past, note], future: future.slice(1), }); }; get canUndo() { return this.state.past.length > 0; } get canRedo() { return this.state.future.length > 0; } } ``` ```tsx function NoteEditor({ initial }: { initial: { title: string; body: string } }) { // `static key` derives identity from the title — one EditorCubit per note. const [state, editor] = useBloc(EditorCubit, { args: initial, select: (s, bloc) => [ s.note.title, s.note.body, bloc.canUndo, bloc.canRedo, ], }); return (
    editor.setTitle(e.target.value)} />