Skip to content

Patterns & Recipes

Common patterns for structuring BlaC applications. Each pattern is drawn from real examples in the codebase.

Model async state explicitly. Use a request ID to handle race conditions when multiple requests overlap.

interface FeedState {
articles: Article[];
status: 'idle' | 'loading' | 'error' | 'success';
error: string | null;
}
class FeedCubit extends Cubit<FeedState> {
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.

When using the persistence plugin, 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:

class SettingsCubit extends Cubit<SettingsState> {
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 for the hydrationChanged event that drives it.

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.

function QuickAdd() {
const [, todo] = useBloc(TodoCubit);
return <button onClick={() => todo.addItem('New item')}>Add Item</button>;
}

This component renders once and never re-renders, regardless of state changes.

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.

class FormCubit extends Cubit<FormState, { section: string }> {
static key = (a: FormCubit['args']) => a.section;
}
function FormPage() {
return (
<>
<FormSection section="billing" />
<FormSection section="shipping" />
</>
);
}
function FormSection({ section }: { section: string }) {
const [state, form] = useBloc(FormCubit, { args: { section } });
// Each section has independent state
return (
<input
value={state.email}
onChange={(e) => 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 for the full identity model.

Use 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:

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:

const stop = watch(AuthCubit, (bloc) => {
if (bloc.state.user) {
analytics.identify(bloc.state.user.id);
return watch.STOP; // one-shot
}
});

The recipes below are the common shapes; Bloc Communication is the full reference for depend() and its lifecycle caveats, and Best Practices covers when cross-bloc coupling is a smell versus a clean dependency.

Use depend() to declare a dependency. It returns a handle that resolves the instance from the registry on demand — .untracked() for a plain read.

class CartCubit extends Cubit<CartState> {
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;
}
}

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.

class CartCubit extends Cubit<CartState> {
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;
}
}
// The component subscribes to CartCubit only — yet re-renders when shipping changes too.
function CartTotal() {
const [, cart] = useBloc(CartCubit);
return <strong>${cart.total.toFixed(2)}</strong>;
}

.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() for the full reference.

Call methods on dependencies to coordinate behavior:

class ChannelCubit extends Cubit<ChannelState> {
private notifications = this.depend(NotificationCubit);
receiveMessage = (message: Message) => {
this.patch({ messages: [...this.state.messages, message] });
this.notifications.untracked().incrementUnread(this.state.channelId);
};
}

When a dependency might not exist yet, use borrowSafe to check and acquire to create on demand:

import { borrowSafe, acquire } from '@blac/core';
class ChannelCubit extends Cubit<ChannelState> {
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);
// ...
};
}

Use onSystemEvent('dispose') to persist data when an instance is cleaned up:

class ChannelCubit extends Cubit<ChannelState> {
constructor() {
super({ channel: null, messages: [] });
this.onSystemEvent('dispose', () => {
if (this.state.channel) {
persistenceService.save(this.state.channel.id, this.state.messages);
}
});
}
}

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:

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 for the full hook reference, the paths parameter, and the environment option (skip a plugin outside development, for example).

For app-wide singletons that should never be disposed (auth, theme, feature flags), use keepAlive:

@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 for the option and Instance Management for how ref-counting and auto-dispose interact.

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:

import { useId } from 'react';
class UploadCubit extends Cubit<UploadState, { _id: string }> {
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 <progress value={state.progress} max={100} />;
}

Two <UploadWidget /> 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.

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.

class TodoCubit extends Cubit<TodoState> {
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);
}
}
function TodoStats() {
const [, todo] = useBloc(TodoCubit);
return (
<span>
{todo.activeCount} active, {todo.completedCount} done
</span>
);
}
// Or use `select` so the getter results gate re-renders:
function TodoStatsSelected() {
const [, todo] = useBloc(TodoCubit, {
select: (_, bloc) => [bloc.activeCount, bloc.completedCount],
});
return (
<span>
{todo.activeCount} active, {todo.completedCount} done
</span>
);
}

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: