Skip to content

Comparison

If you are evaluating BlaC against Zustand or Jotai, the first question is usually skeptical: why a class? Hooks-first stores feel lighter, and “OOP for state” reads like a step backwards. This page makes the affirmative case first, then puts the three side by side honestly — including the cases where Zustand or Jotai is the better choice.

This is positioning, not a feature scoreboard. None of these libraries is wrong; they make different bets. The goal here is to make BlaC’s bet legible so you can tell whether it is yours.

The class is not ceremony for its own sake. Each thing it gives you is a direct answer to a recurring pain in store-closure or atom-graph designs.

Logic is colocated with the state it mutates. State shape, the methods that change it, derived values, and async flows live in one cohesive unit. A CartCubit holds items and also holds addItem, removeItem, checkout, and get total. You do not chase a mutation across a reducer file, an action-creators file, and a selectors file — the concern is one class. When the cart’s rules change, you edit one place.

You can test it without React. A Cubit has no dependency on React, the DOM, or hooks. You construct it, call methods, and assert on state and getters directly — a plain unit test, no render harness, no act(), no test renderer:

const cart = new CartCubit();
cart.addItem({ id: 'a', price: 10, qty: 2 });
expect(cart.total).toBe(20);

Store-closure designs can be tested headless too, but it is less direct: you reach through the store’s getState/setState rather than calling a method on an object, and shared mutable module state between tests is easy to leak. A fresh new CartCubit() per test is the natural isolation boundary.

Getters are derived state, for free. A get total() recomputes from items on every read, so it can never drift from its source. The tracker records the getter’s underlying reads, so a component that reads cart.total wakes when the source paths the getter touched change — no useMemo, no reselect, no memo input arrays to keep in sync. Use select when the computed result itself should gate re-renders. Derived state in an atom library is its own atom you compose and wire; here it is a method body.

The lineage is flutter_bloc, deliberately. “Business Logic Component” is the Flutter pattern: a class that owns a slice of logic and emits state. BlaC keeps the Cubit half of that lineage (methods you call, not events you dispatch — there is no Bloc event class; see Best Practices) because the testability and colocation win travels straight across from Flutter to React.

Re-renders are per consumer, automatically. Each useBloc call site gets its own render-time proxy and its own recorded set of read paths. Two components reading the same instance subscribe to different fields and wake independently — without you writing a selector per subscription. The dependency declaration is the JSX. The full mechanism is in Mental Model.

A fixed rubric across the three. Read it as “how each library answers this question,” not as winners and losers.

BlaCZustandJotai
State modelClass (Cubit) with methods + getterscreate() store closure with set/getComposable atoms (primitive + derived)
Render optimizationAuto-tracked read paths, per consumer (no selector)Selector you pass to the hook (useStore(s => …))Per-atom subscription; granularity from atom split
BoilerplateClass + super(initial); methods are intentsMinimal; one closureMinimal per atom; grows with atom count + wiring
ProvidersNone — global ref-counted registryNone by default (optional context store)Provider for scoping/SSR (optional at root)
TS inferenceState flows from the class type paramStrong; sometimes needs a typed create<T>()Strong; derived-atom types inferred
Asyncasync methods on the class; explicit status stateAsync actions in the store closureAsync atoms (promise atoms, Suspense-friendly)
DevToolsFirst-party @blac/devtools-connect pluginRedux DevTools middlewareJotai DevTools / Redux DevTools integration
SSRPer-request isolation via instance keys / registrySupported; hydrate store on the clientStrong story via Provider + hydration
Framework-agnosticCore is framework-agnostic; React adapter is separateCore is framework-agnostic; vanilla + ReactReact-centric (Jotai core targets React)
Bundle sizeMeasured: core ~6.88 kB, react ~2.6 kB (brotli)Tiny; see their published claimsTiny core; grows with utility imports

Bundle-size detail is in its own section below — only BlaC’s number is measured here; the others are described qualitatively on purpose.

BlaC borrows liberally and differs deliberately. Here is where it sits relative to tools you likely know — including Redux, MobX, and Context for completeness — and when one of them is the better fit.

LibraryWhat BlaC borrowsWhat BlaC does differentlyReach for it instead when
Redux (Toolkit)Single source of truth per concern; immutable updates; a devtools/time-travel storyNo global reducer/action/dispatch indirection; logic is methods on a class, not reducers + action creators; auto-tracking replaces hand-written selectorsYou need a strict, serializable action log as the center of your architecture, or large-team conventions built around RTK
ZustandNo-provider, hook-first store; minimal APIState lives in a class with methods and getters (not a create((set) => ...) closure); re-render scope is per-read-path automatically, not a selector you pass to the hookYou want the smallest possible store with no class ceremony and are happy writing selectors per subscription
MobXRead-to-subscribe transparent reactivity; derived values feel freeReactivity is render-time path recording over immutable snapshots, not observable mutable objects with autorun; you replace state, you don’t mutate it; no decorators required for trackingYou want deep observable graphs with computed/reaction and prefer mutate-in-place ergonomics
React ContextTree-free consumption ergonomics (just call a hook)Sharing is registry identity, not provider position; no subtree re-render on change; instances are ref-counted and disposableThe value is genuinely tree-scoped config (theme, locale, a request) that should follow the component tree, change rarely, and never needs disposal
Jotai / RecoilFine-grained, atom-like subscription granularityGranularity comes from which paths you read in one state object, not from composing many atoms; one cohesive class instead of a graph of atomsYou think in independent composable atoms and want bottom-up derived-atom graphs

What to take from the table: BlaC’s distinctive bet is transparent reactivity (like MobX) over immutable snapshots (like Redux), with no provider and automatic lifecycle (unlike both). The granularity of an atom library without managing atoms; the testability of a class without the reducer boilerplate. If your problem is genuinely a serializable event log, a tree-scoped config value, or a handful of useState hooks, the honest answer is that one of those tools fits better — see the “when to use BlaC” framing in the Introduction.

A counter is too small to need any of these libraries — that is exactly why it isolates the shape of each model. Watch where the logic lives and how a component subscribes.

Zustand — logic in a create() closure; the component passes a selector to scope its re-renders.

import { create } from 'zustand';
const useCounter = create<{
count: number;
increment: () => void;
}>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
function Counter() {
const count = useCounter((s) => s.count);
const increment = useCounter((s) => s.increment);
return <button onClick={increment}>Count: {count}</button>;
}

Jotai — state and its updater are atoms; the component subscribes to the atom it reads.

import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>;
}

BlaC — logic in a class; the component reads state.count and that read is the subscription.

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 });
}
import { useBloc } from '@blac/react';
function Counter() {
const [state, counter] = useBloc(CounterCubit);
return <button onClick={counter.increment}>Count: {state.count}</button>;
}

The three converge on roughly the same line count for a counter. The divergence shows up as logic grows: the Zustand closure and the Jotai atom set accumulate updaters and derived atoms inline, while the Cubit accumulates methods and getters as a typed unit you can test in isolation. The render-scoping line — useCounter((s) => s.count) vs useAtom(countAtom) vs an untouched state.count read — is the per-consumer-isolation difference made literal: BlaC infers it from the read, the other two make it explicit (a selector, or which atom you reach for).

Choose BlaC when state is shared, complex, and worth testing without React: validation, derived values, async flows, cross-bloc coordination. You want logic colocated in a typed unit, automatic per-read re-render scoping, and a ref-counted lifecycle with no providers — and you are comfortable with classes as the organizing idea.

Choose Zustand when you want the smallest possible store with no class ceremony, are happy writing a selector per subscription to scope re-renders, and your logic stays close to a flat closure. It is an excellent floor for “I just need a shared store.”

Choose Jotai when you think bottom-up in independent, composable pieces — derived-atom graphs, fine-grained Suspense-friendly async, state that is naturally a web of small values rather than a few cohesive slices. Atoms shine when the composition is the model.

If a piece of state lives in one component and never travels, none of the three earns its weight — reach for useState. See When to use BlaC.

BlaC’s footprint is measured in CI with size-limit on the published ESM build, brotli-compressed:

  • @blac/core is about 6.88 kB (brotli).
  • @blac/react is about 2.6 kB (brotli), excluding react / react-dom peers.

These are the real numbers from the size budget that gates every build, not estimates. A React app pulls in both, so the floor is roughly 9.5 kB before your own code.

For Zustand and Jotai, consult their own published figures rather than any number quoted here. Both are deliberately small — Zustand advertises a tiny core, and Jotai’s primitives are minimal — but a precise byte count depends on which utilities and middleware you import, your bundler, and your compression settings, so inventing exact competitor numbers would be dishonest. The fair summary: all three are small enough that bundle size is rarely the deciding factor between them. Pick on model fit, not bytes.