Skip to content

Patterns

Structural patterns for BlaC applications: shapes worth reusing, not primitives already documented elsewhere. For the mechanics behind args/deps/instance identity, see Passing Inputs and Instance Management. For copy-paste solutions to specific problems, see Recipes.

Model async state explicitly with a status enum, and guard against overlapping requests with a monotonic request id:

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);
if (id !== this.requestId) return; // ignore stale responses
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. See Best Practices for the principle behind this.

When state may arrive asynchronously from a persistence plugin, await this.$blac.hydration.wait() in init before fetching, so a stale network response doesn’t overwrite restored values:

protected override async init() {
await this.$blac.hydration.wait();
await this.refreshFromServer();
}

A component that only triggers actions and never reads state needs no selector — reading nothing means auto-tracking records nothing to wake it:

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

Use watch to observe state changes from non-React code — the callback receives the bloc instance, fires once immediately, then on every change:

import { watch } from '@blac/core';
watch(TodoCubit, (bloc) => {
localStorage.setItem('todos', JSON.stringify(bloc.state.items));
});

Return watch.STOP from the callback (or call the returned unsubscribe function) to stop watching.

Use this.depend(Other) to declare a dependency, .untracked() for a plain read or method call, and .track() when a getter’s cross-bloc read should also drive re-renders in whatever reads that getter. Bloc Communication is the full reference; Best Practices covers when the coupling is a smell. One recipe worth calling out here: lazy instance creation, checking with borrowSafe before acquire-ing a dependency that might not exist yet:

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
}
}

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 — useful for cross-cutting concerns like analytics or logging. Every hook takes PluginContext first; ctx.container is the bloc the event is about, and prev/next in onStateChange are state objects, not the bloc:

const analyticsPlugin: BlacPlugin = {
name: 'analytics',
version: '1.0.0',
onStateChange(ctx, prev, next, paths) {
analytics.track('state_changed', { name: ctx.container?.name, from: prev, to: next });
},
};
getPluginManager().install(analyticsPlugin);

See Plugin Authoring for the full hook reference.

Named instances, app-wide singletons, and per-mount-private instances are all the same args-keying mechanism applied to a different shape of “which instance.” See Instance Management for keepAlive and ref-counting mechanics, and Passing Inputs for keying with static key and per-mount identity via useId().

Define getters for derived state instead of storing the computed value. Reading a getter in render auto-tracks the this.state paths it touches; use select when the getter’s return value, not its source paths, should 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;
}
}