Skip to content

Coming from Zustand

Zustand and BlaC share the same “no provider” philosophy and a minimal surface area. The divergence is in where logic lives (a closure vs a class), how re-renders are scoped (an explicit selector vs auto-tracked read paths), and what you get for free as complexity grows (a flat store vs a typed unit you can test in isolation).

If you are comfortable with Zustand but find yourself writing many selectors, leaking logic into components, or struggling to test state mutations, BlaC is a natural next step.

Zustand termBlaC termNotes
create((set, get) => ...)class MyCubit extends Cubit<S>Logic lives in the class body instead of a closure
set(partial)this.patch(partial) / this.emit(next)patch deep-merges; emit replaces
get()this.stateRead current state from the instance property
useStore(selector)useBloc(MyCubit) + auto-tracked stateNo selector needed; tracking is inferred from what the render reads
useStore((s) => s.count)useBloc(MyCubit) reading state.countReading state.count in render is the subscription
Middleware (devtools, persist)First-party plugins (@blac/devtools-connect, @blac/plugin-persist)Plugin API is explicit; installed once globally
subscribeWithSelectorwatch(MyCubit, cb)Outside React; no middleware needed
createWithEqualityFnselect option on useBlocselect: (s, b) => [s.derived] — re-render only when array changes
Slices pattern (combine)Separate Cubit per concernEach Cubit is already a self-contained slice
Immer middlewarepatch(partial) (built-in deep-merge)Or use spread in update — no middleware required

Zustand stores logic in a create() closure. The object it returns is both the state and the actions — a flat record with properties and function values mixed together. Re-render scoping requires an explicit selector passed to every hook call.

BlaC separates the concerns: state is typed separately from the class body, methods are class methods, and the hook returns a [state, bloc] tuple. Because the hook wraps state in a Proxy during render, it records which paths the component reads — no selector needed.

Zustand

import { create } from 'zustand';
interface BearStore {
bears: number;
honey: number;
increasePopulation: () => void;
eatHoney: () => void;
reset: () => void;
}
const useBearStore = create<BearStore>((set) => ({
bears: 0,
honey: 10,
increasePopulation: () => set((s) => ({ bears: s.bears + 1 })),
eatHoney: () => set((s) => ({ honey: Math.max(0, s.honey - 1) })),
reset: () => set({ bears: 0, honey: 10 }),
}));
function BearCounter() {
// explicit selector — component only re-renders when bears changes
const bears = useBearStore((s) => s.bears);
const increase = useBearStore((s) => s.increasePopulation);
return <button onClick={increase}>Bears: {bears}</button>;
}
function HoneyJar() {
const honey = useBearStore((s) => s.honey);
const eat = useBearStore((s) => s.eatHoney);
return <button onClick={eat}>Honey: {honey}</button>;
}

BlaC

class
class BearCubit
BearCubit
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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
<{
bears: number
bears
: number;
honey: number
honey
: number }> {
constructor() {
super({
bears: number
bears
: 0,
honey: number
honey
: 10 });
}
BearCubit.increasePopulation: () => void
increasePopulation
= () => this.
StateContainer<{ bears: number; honey: number; }, void, Record<string, never>>.patch(partial: {
bears?: number | undefined;
honey?: 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 legacy listeners and 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
({
bears?: number | undefined
bears
: this.
StructuralContainer<{ bears: number; honey: number; }>.state: {
bears: number;
honey: number;
}
state
.
bears: number
bears
+ 1 });
BearCubit.eatHoney: () => void
eatHoney
= () => this.
StateContainer<{ bears: number; honey: number; }, void, Record<string, never>>.patch(partial: {
bears?: number | undefined;
honey?: 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 legacy listeners and 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
({
honey?: number | undefined
honey
:
var Math: Math

An intrinsic object that provides basic mathematics functionality and constants.

Math
.
Math.max(...values: number[]): number

Returns the larger of a set of supplied numeric expressions.

@paramvalues Numeric expressions to be evaluated.

max
(0, this.
StructuralContainer<{ bears: number; honey: number; }>.state: {
bears: number;
honey: number;
}
state
.
honey: number
honey
- 1) });
BearCubit.reset: () => void
reset
= () => this.
StateContainer<{ bears: number; honey: number; }, void, Record<string, never>>.emit(next: {
bears: number;
honey: number;
}): void
emit
({
bears: number
bears
: 0,
honey: number
honey
: 10 });
}
import { useBloc } from '@blac/react';
function BearCounter() {
// reading state.bears → component re-renders only when bears changes
const [state, bear] = useBloc(BearCubit);
return (
<button onClick={bear.increasePopulation}>Bears: {state.bears}</button>
);
}
function HoneyJar() {
// reading state.honey → re-renders only when honey changes
const [state, bear] = useBloc(BearCubit);
return <button onClick={bear.eatHoney}>Honey: {state.honey}</button>;
}

BearCounter and HoneyJar re-render independently — each only wakes on the path it actually reads. With Zustand you would write two useStore((s) => s.x) selectors by hand. With BlaC the read is the subscription; no selector required.

The flat closure model works well for small stores. As a slice accumulates validation, derived values, and async flows, the Zustand pattern collapses everything into one growing object literal. The BlaC Cubit keeps those concerns in class methods and getters:

Zustand — a growing store

const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
removeItem: (id) =>
set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
// getter-like value mixed into the store shape
get total() {
return get().items.reduce((sum, i) => sum + i.price * i.qty, 0);
},
checkout: async () => {
const items = get().items;
await api.checkout(items);
set({ items: [] });
},
}));

BlaC — the same cart

class
class CartCubit
CartCubit
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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
<{
items: CartItem[]
items
:
interface CartItem
CartItem
[] }> {
constructor() {
super({
items: CartItem[]
items
: [] });
}
CartCubit.addItem: (item: CartItem) => void
addItem
= (
item: CartItem
item
:
interface CartItem
CartItem
) =>
this.
StateContainer<{ items: CartItem[]; }, void, Record<string, never>>.patch(partial: {
items?: readonly {
id?: string | undefined;
price?: number | undefined;
qty?: number | undefined;
}[] | 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 legacy listeners and 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
({
items?: readonly {
id?: string | undefined;
price?: number | undefined;
qty?: number | undefined;
}[] | undefined
items
: [...this.
StructuralContainer<{ items: CartItem[]; }>.state: {
items: CartItem[];
}
state
.
items: CartItem[]
items
,
item: CartItem
item
] });
CartCubit.removeItem: (id: string) => void
removeItem
= (
id: string
id
: string) =>
this.
StateContainer<{ items: CartItem[]; }, void, Record<string, never>>.patch(partial: {
items?: readonly {
id?: string | undefined;
price?: number | undefined;
qty?: number | undefined;
}[] | 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 legacy listeners and 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
({
items?: readonly {
id?: string | undefined;
price?: number | undefined;
qty?: number | undefined;
}[] | undefined
items
: this.
StructuralContainer<{ items: CartItem[]; }>.state: {
items: CartItem[];
}
state
.
items: CartItem[]
items
.
Array<CartItem>.filter(predicate: (value: CartItem, index: number, array: CartItem[]) => unknown, thisArg?: any): CartItem[] (+1 overload)

Returns the elements of an array that meet the condition specified in a callback function.

@parampredicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.

@paramthisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.

filter
((
i: CartItem
i
) =>
i: CartItem
i
.
CartItem.id: string
id
!==
id: string
id
) });
get
CartCubit.total: number
total
() {
return this.
StructuralContainer<{ items: CartItem[]; }>.state: {
items: CartItem[];
}
state
.
items: CartItem[]
items
.
Array<CartItem>.reduce<number>(callbackfn: (previousValue: number, currentValue: CartItem, currentIndex: number, array: CartItem[]) => number, initialValue: number): number (+2 overloads)

Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function.

@paramcallbackfn A function that accepts up to four arguments. The reduce method calls the callbackfn function one time for each element in the array.

@paraminitialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value.

reduce
((
sum: number
sum
,
i: CartItem
i
) =>
sum: number
sum
+
i: CartItem
i
.
CartItem.price: number
price
*
i: CartItem
i
.
CartItem.qty: number
qty
, 0);
}
CartCubit.checkout: () => Promise<void>
checkout
= async () => {
await
const api: {
checkout(items: CartItem[]): Promise<void>;
}
api
.
function checkout(items: CartItem[]): Promise<void>
checkout
(this.
StructuralContainer<{ items: CartItem[]; }>.state: {
items: CartItem[];
}
state
.
items: CartItem[]
items
);
this.
StateContainer<{ items: CartItem[]; }, void, Record<string, never>>.emit(next: {
items: CartItem[];
}): void
emit
({
items: CartItem[]
items
: [] });
};
}

The Cubit is testable without React or a mock store wrapper:

const
const cart: CartCubit
cart
= new
constructor CartCubit(): CartCubit
CartCubit
();
const cart: CartCubit
cart
.
CartCubit.addItem: (item: CartItem) => void
addItem
({
CartItem.id: string
id
: 'a',
CartItem.price: number
price
: 10,
CartItem.qty: number
qty
: 2 });
const cart: CartCubit
cart
.
CartCubit.addItem: (item: CartItem) => void
addItem
({
CartItem.id: string
id
: 'b',
CartItem.price: number
price
: 5,
CartItem.qty: number
qty
: 1 });
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(
const cart: CartCubit
cart
.
CartCubit.total: number
total
); // 25

No act(), no render harness, no getState() reached through a store handle.

Zustand middleware wraps the store creator (devtools, persist, immer). BlaC uses a plugin system installed once globally:

import { getPluginManager } from '@blac/core';
import { createDevToolsBrowserPlugin } from '@blac/devtools-connect';
import { createIndexedDbPersistPlugin } from '@blac/plugin-persist';
import { LoggingPlugin } from '@blac/logging-plugin';
getPluginManager().install(createDevToolsBrowserPlugin(), {
environment: 'development',
});
getPluginManager().install(createIndexedDbPersistPlugin());
getPluginManager().install(new LoggingPlugin({ level: 'info' }), {
environment: 'development',
});

No middleware composition, no devtools(persist(immer(...))) nesting. Plugins observe all Cubits globally; you can opt individual Cubits out via @blac({ excludeFromDevTools: true }).

Zustand’s subscribeWithSelector and its vanilla store.subscribe travel to BlaC’s watch:

const
const unwatch: () => void
unwatch
=
watch<typeof BearCubit>(bloc: typeof BearCubit | BlocRef<typeof BearCubit>, callback: (bloc: BearCubit) => void | unique symbol): () => void (+1 overload)
watch
(
class BearCubit
BearCubit
, (
bloc: BearCubit
bloc
) => {
var document: Document

window.document returns a reference to the document contained in the window.

MDN Reference

document
.
Document.title: string

The document.title property gets or sets the current title of the document. When present, it defaults to the value of the .

MDN Reference

title
= `Bears: ${
bloc: BearCubit
bloc
.
StructuralContainer<{ bears: number; honey: number; }>.state: {
bears: number;
honey: number;
}
state
.
bears: number
bears
}`;
});
// later:
const unwatch: () => void
unwatch
();

watch observes a Cubit’s state from outside React. No store handle, no selector middleware needed.

The Zustand slices pattern (combine, createSlice) splits one large store into sections that are merged back together. BlaC separates concerns at the class level — each Cubit is already an independent slice. Cross-cubit access uses this.depend():

class
class CartCubit
CartCubit
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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
<{
items: string[]
items
: string[] }> {
private
CartCubit.auth: DepHandle<typeof AuthCubit>
auth
= this.
StateContainer<{ items: string[]; }, void, Record<string, never>>.depend<typeof AuthCubit>(Type: typeof AuthCubit, defaultArgs?: void | undefined): DepHandle<typeof AuthCubit>

Declare a cross-bloc dependency. Returns a branded handle with two accessors — the dep instance is resolved against the registry on each call, which keeps the surface immune to dep-instance churn:

  • handle.track(options?) — reactive read. Returns [state, instance]. Inside a getter reached through the React proxy this subscribes the reading component to the dep's changes (base impl: live, no subscription).
  • handle.untracked(options?) — returns the live instance with no tracking, for imperative method calls and one-off reads.

defaultArgs resolves the dep instance when an accessor is called without its own args; per-call options.args overrides it and can derive from current state. This does NOT auto-resubscribe outside the React proxy; non-React consumers needing updates should subscribe explicitly.

depend
(
class AuthCubit
AuthCubit
);
constructor() {
super({
items: string[]
items
: [] });
}
CartCubit.checkout: () => Promise<void>
checkout
= async () => {
const
const authState: {
user: string | null;
}
authState
= this.
CartCubit.auth: DepHandle<typeof AuthCubit>
auth
.
DepHandle<typeof AuthCubit>.untracked(options?: DepAccessOptions<typeof AuthCubit> | undefined): AuthCubit
untracked
().
StructuralContainer<{ user: string | null; }>.state: {
user: string | null;
}
state
;
if (!
const authState: {
user: string | null;
}
authState
.
user: string | null
user
) throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
('Not logged in');
// ... proceed
};
}

No slice merging, no shared-store handle. depend returns a handle that resolves the Cubit from the registry, keeping the two slices decoupled.

The most common Zustand pattern is a per-hook selector:

// Zustand — every subscription needs a selector
const count = useCounterStore((s) => s.count);
const name = useUserStore((s) => s.profile.name);

BlaC infers the subscription from the render read:

// BlaC — read it, that's the subscription
const [counterState] = useBloc(CounterCubit);
const [userState] = useBloc(UserCubit);
// count = counterState.count, name = userState.profile.name
// no selectors written

When you do need finer control — a computed value, a cross-field condition — use the select option:

const [state] = useBloc(CartCubit, {
select: (s, cart) => [cart.total], // re-render only when total changes
});
ZustandBlaC
create() closure holds state + actionsClass body holds state type + methods
Explicit selector per hook callAuto-tracked read paths; select for computed values
Middleware stack (devtools(persist(...)))Plugin list installed once globally
subscribeWithSelector for outside-Reactwatch(Class, cb) — no middleware needed
Slices merged via combineIndependent Cubits; cross-cubit via depend()
Mutations tested via getState()Mutations tested by calling methods on new MyCubit()
immer middleware for nested mergespatch(partial) built in

BlaC earns its weight when state accumulates logic worth testing, derived values, or async flows. If your state is a handful of booleans in a flat object that never grows a method, Zustand’s closure is the lower-overhead choice. See When to use BlaC.

  • Comparison — BlaC vs Zustand vs Jotai side by side, including when Zustand is the better fit
  • Core Concepts — state containers, registry, dependency tracking
  • useBloc — full hook reference with args, select, onMount
  • Dependency Tracking — auto-tracking and select in depth
  • Patterns & Recipes — cross-bloc deps, persistence, async