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 — that page is the concrete how-to. This page is the why behind the choices.
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, which extends the abstract StateContainer. You change state by calling methods that call emit / update / patch. There is no add(event) dispatch layer.
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.
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:
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. patch accepts a DeepPartial<S> 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.
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.
// Good — totals are getters; state has one source of truth
interface CartState {
items: { id:string; price:number; qty:number }[];
}
classCartCubitextendsCubit<CartState> {
constructor() {
super({ items: [] });
}
getsubtotal() {
returnthis.state.items.reduce((sum, i)=> sum + i.price* i.qty, 0);
}
getitemCount() {
returnthis.state.items.length;
}
}
// Bad — subtotal/itemCount stored alongside items; every mutation must
// recompute them by hand, and one missed update silently desyncs the UI
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; 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.
// 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).
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.
// Good — a synthetic `_id` keys the instance; endpoint rides along as config
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:
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.
// Good — init seeds sync state and starts the load; it does not await
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).
// Good — CartCubit depends on ShippingCubit, one direction only
// Bad — a cycle: Cart depends on Shipping AND Shipping depends on Cart.
// Reading total now risks infinite recursion, and disposal order is undefined.
classShippingCubitextendsCubit<ShippingState> {
private cart =this.depend(CartCubit); // closes the loop — don't
}
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.
// Good — the derivation declares its own reactivity; consumers stay simple.
classCartCubitextendsCubit<CartState> {
private shipping =this.depend(ShippingCubit);
gettotal() {
const [shipping] = this.shipping.track();
returnthis.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().
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.
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.
// Good — reads a getter, calls a method; no logic in the view
functionCartSummary() {
const [,cart] = useBloc(CartCubit);
return (
<footer>
<span>{cart.itemCount} items</span>
<strong>${cart.total.toFixed(2)}</strong>
<buttononClick={cart.checkout}>Checkout</button>
</footer>
);
}
// Bad — totals computed in the view (duplicating bloc logic), and the
// button mutates state through the bloc instance directly
functionCartSummary() {
const [state,cart] = useBloc(CartCubit);
const total = state.items.reduce((s, i) => s + i.price * i.qty,0); // belongs in a getter
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.
// Good — pure bloc test, no DOM
const cart = newCartCubit();
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.
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.
// 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
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.
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.
// 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.