Skip to content

Best Practices

This page is about judgment, not mechanics: how to shape state, how to choose between input lanes, how to model async work, and which habits quietly cause bugs. For copy-paste recipes, see Patterns.

Principle: reach for Cubit. BlaC does not ship a separate Bloc class.

If you come from flutter_bloc, you may expect an event-driven Bloc you dispatch events to. BlaC has one concrete base: Cubit. You change state by calling methods that call emit/update/patch — there is no add(event) dispatch layer.

// 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 });
}

If you genuinely want an event-sourced log, model it explicitly inside a Cubit (an events: Event[] array + a reducer method) rather than looking for a framework Bloc.

State shape: flat, serializable, and free of derived values

Section titled “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.

  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.
  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.
// Bad — subtotal/itemCount stored alongside items; every mutation must
// recompute them by hand, and one missed update silently desyncs the UI
interface
interface CartState
CartState
{
CartState.items: {
id: string;
price: number;
qty: number;
}[]
items
: {
id: string
id
: string;
price: number
price
: number;
qty: number
qty
: number }[];
CartState.subtotal: number
subtotal
: number; // derived — do not store
CartState.itemCount: number
itemCount
: number; // derived — do not store
}

See Patterns: getter-based computed values for the recipe.

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.

You have…Use
Serializable data that defines which instance (a userId, an endpoint, a filter set)args — hashed into the instance key
A non-serializable handle (a useRef, a stable useCallback, an external API object)deps — never keys identity
An opaque per-mount id or externally-managed tokena synthetic args field + static key

The full mechanics — wiring deps from a mount effect, static key, per-mount private instances via useId(), and the inline-callback staleness gotcha — live in Passing Inputs to Blocs; this is just the judgment call.

Principle: model async outcomes as explicit state, guard against stale responses, and never block init.

Don’t infer “loading” from data === null — hold an explicit status so the UI can distinguish never loaded, loading, error, and empty success:

// Bad — overloaded null forces the UI to guess
interface FeedState {
articles: Article[] | null; // null = loading? error? empty? unknown.
}

A monotonic request id is simpler than AbortController for ignoring stale responses when requests can overlap — see the async loading recipe for the full pattern.

init(args) runs once, synchronously, before the first state snapshot — so it’s the wrong place to await. Kick async work off fire-and-forget and let a loading status carry the rest:

// Bad — init cannot be awaited by the framework; consumers render before
// this resolves, and an unawaited rejection is swallowed
class UserCubit extends Cubit<UserState, { userId: string }> {
protected async init(args: { userId: string }) {
this.emit(await api.fetchUser(args.userId));
}
}

If state arrives asynchronously from a persistence plugin, await this.$blac.hydration.wait() inside a fire-and-forget init before touching the network — see Persistence.

Cross-bloc dependencies: this.depend, and avoid cycles

Section titled “Cross-bloc dependencies: this.depend, and avoid cycles”

Principle: declare cross-bloc reads with this.depend(Other); keep the dependency graph a DAG.

// 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<ShippingState> {
private cart = this.depend(CartCubit); // closes the loop — don't
}

If two blocs reach into each other, that’s usually a sign the shared concern belongs in a third bloc both depend on, or the two should be one. .untracked() reads and method calls do not subscribe you — cross-bloc re-render tracking is opt-in via .track(). The full reference, including the two depend gotchas (no held reference; unsafe to read in a constructor) and .track() semantics, is Bloc Communication.

Principle: components read tracked state and call methods. They should not hold derived logic, mutate state, or own business rules.

// 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 (
<footer>
<strong>${total.toFixed(2)}</strong>
<button onClick={() => (cart.state.items = [])}>Clear</button>{' '}
{/* never mutate */}
</footer>
);
}

A component that only triggers actions and never displays state does not need a selector — see Action-only components.

Principle: test blocs as plain classes; test components against the registry. Isolate every test.

// 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, no hidden global reads. The full toolkit is in Testing Overview.

A quick-reference of habits to drop, each with the fix. Many have a symptom-first entry in Troubleshooting.

// Bad
this.state.items.push(item);
// Fix — produce a new value through a mutation method
this.patch({ items: [...this.state.items, item] });
// 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(/* ... */); }
// 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 } });

There is no separate instanceId channel — see Passing Inputs.

Refs, callbacks, class instances, and Date/Map/Set in args produce an unstable instance key — a new instance every render. Handles go in deps, wired from a mount effect (not a useBloc option) — see Passing Inputs.

Opting out of tracking when you don’t need to

Section titled “Opting out of tracking when you don’t need to”
// 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 once profiling shows it matters — not as a default. A select function must be referentially stable, or a fresh function each render re-keys the subscription.