Skip to content

Quick Start

Terminal window
pnpm 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.

{
"compilerOptions": {
"target": "ESNext",
"jsx": "react-jsx",
"strict": true,
"useDefineForClassFields": true,
"experimentalDecorators": true, // only if you use @blac(...) decorator syntax
},
}

A Cubit is a class that holds state and exposes methods to change it.

import {
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

Cubit<S> is a StateContainer<S> with emit / patch exposed as public mutation surface. Today it adds nothing structurally beyond StateContainer — both are inherited from the underlying StructuralContainer<S>. Kept as a real class (not a type alias) because downstream code does instance instanceof Cubit checks.

The class body is intentionally empty: a no-op emit override would still go through applyState, and patch is inherited from StructuralContainer (path-diffed, microtask-flushed). A caller that wants "skip if no real change" patch semantics can wrap patch themselves or call emit after a manual equality check.

Cubit
} from '@blac/core';
class
class CounterCubit
CounterCubit
extends
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

Cubit<S> is a StateContainer<S> with emit / patch exposed as public mutation surface. Today it adds nothing structurally beyond StateContainer — both are inherited from the underlying StructuralContainer<S>. Kept as a real class (not a type alias) because downstream code does instance instanceof Cubit checks.

The class body is intentionally empty: a no-op emit override would still go through applyState, and patch is inherited from StructuralContainer (path-diffed, microtask-flushed). A caller that wants "skip if no real change" patch semantics can wrap patch themselves or call emit after a manual equality check.

Cubit
<{
count: number
count
: number }> {
constructor() {
super({
count: number
count
: 0 });
}
CounterCubit.increment: () => void
increment
= () => this.
StateContainer<{ count: number; }, void, Record<string, never>>.emit(next: {
count: number;
}): void
emit
({
count: number
count
: this.
StructuralContainer<{ count: number; }>.state: {
count: number;
}
state
.
count: number
count
+ 1 });
CounterCubit.decrement: () => void
decrement
= () => this.
StructuralContainer<{ count: number; }>.update(fn: (state: {
count: number;
}) => {
count: number;
}): void
update
((
s: {
count: number;
}
s
) => ({
count: number
count
:
s: {
count: number;
}
s
.
count: number
count
- 1 }));
CounterCubit.reset: () => void
reset
= () => this.
StateContainer<{ count: number; }, void, Record<string, never>>.patch(partial: {
count?: number | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
count?: number | undefined
count
: 0 });
}

Three ways to change state:

MethodWhat it doesWhen to use
emit(newState)Replace the entire stateYou have the full new state ready
update(fn)Derive new state from currentYou need to read current state first
patch(partial)Deep-merge partial changes (DeepPartial<S>)You want to update some fields and keep the rest

The useBloc hook connects your component to a Cubit.

import { useBloc } from '@blac/react';
function Counter() {
const [state, counter] = useBloc(CounterCubit);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={counter.increment}>+</button>
<button onClick={counter.decrement}>-</button>
<button onClick={counter.reset}>Reset</button>
</div>
);
}

useBloc returns a tuple:

  • state — the current state snapshot (tracked for re-renders)
  • counter — the Cubit instance (call methods on it)

By default, every component that calls useBloc(CounterCubit) gets the same instance. State is automatically shared.

function CounterDisplay() {
const [state] = useBloc(CounterCubit);
return <p>Count: {state.count}</p>;
}
function CounterControls() {
const [, counter] = useBloc(CounterCubit);
return <button onClick={counter.increment}>+</button>;
}
function App() {
return (
<>
<CounterDisplay />
<CounterControls />
</>
);
}

When CounterControls calls increment, CounterDisplay re-renders with the new count. No providers, no context, no prop drilling.

Keep logic in the class, not in the component.

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. For async work (loading flags, fetches, request guards), see Patterns & Recipes.

An async action is just a method that awaits 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.

type
type UserState = {
status: "idle";
} | {
status: "loading";
} | {
status: "success";
user: User;
} | {
status: "error";
message: string;
}
UserState
=
| {
status: "idle"
status
: 'idle' }
| {
status: "loading"
status
: 'loading' }
| {
status: "success"
status
: 'success';
user: User
user
:
interface User
User
}
| {
status: "error"
status
: 'error';
message: string
message
: string };
class
class UserCubit
UserCubit
extends
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

Cubit<S> is a StateContainer<S> with emit / patch exposed as public mutation surface. Today it adds nothing structurally beyond StateContainer — both are inherited from the underlying StructuralContainer<S>. Kept as a real class (not a type alias) because downstream code does instance instanceof Cubit checks.

The class body is intentionally empty: a no-op emit override would still go through applyState, and patch is inherited from StructuralContainer (path-diffed, microtask-flushed). A caller that wants "skip if no real change" patch semantics can wrap patch themselves or call emit after a manual equality check.

Cubit
<
type UserState = {
status: "idle";
} | {
status: "loading";
} | {
status: "success";
user: User;
} | {
status: "error";
message: string;
}
UserState
> {
private
UserCubit.requestId: number
requestId
= 0;
constructor() {
super({
status: "idle"
status
: 'idle' });
}
UserCubit.load: (id: string) => Promise<void>
load
= async (
id: string
id
: string) => {
const
const reqId: number
reqId
= ++this.
UserCubit.requestId: number
requestId
; // claim the latest slot
this.
StateContainer<UserState, void, Record<string, never>>.emit(next: UserState): void
emit
({
status: "loading"
status
: 'loading' });
try {
const
const user: User
user
= await
const api: {
fetchUser(id: string): Promise<User>;
}
api
.
function fetchUser(id: string): Promise<User>
fetchUser
(
id: string
id
);
if (
const reqId: number
reqId
!== this.
UserCubit.requestId: number
requestId
) return; // a newer call won; bail
this.
StateContainer<UserState, void, Record<string, never>>.emit(next: UserState): void
emit
({
status: "success"
status
: 'success',
user: User
user
});
} catch (
function (local var) e: unknown
e
) {
if (
const reqId: number
reqId
!== this.
UserCubit.requestId: number
requestId
) return;
this.
StateContainer<UserState, void, Record<string, never>>.emit(next: UserState): void
emit
({
status: "error"
status
: 'error',
message: string
message
:
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(
function (local var) e: unknown
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.

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.

  • Core Concepts — A quick tour of registry, tracking, and lifecycle
  • Mental Model — The deep version of “what just happened?”
  • Patterns & Recipes — Async patterns, cross-bloc communication, persistence
  • Cubit — Full Cubit API
  • useBloc — Hook options and tracking modes
  • Core Concepts — the quick conceptual tour
  • Cubitemit / update / patch in full
  • useBloc — every hook option and both tracking modes
  • DevTools — inspect state and re-renders in real time