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 = newCartCubit();
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.
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.
Library
What BlaC borrows
What BlaC does differently
Reach for it instead when
Redux (Toolkit)
Single source of truth per concern; immutable updates; a devtools/time-travel story
No global reducer/action/dispatch indirection; logic is methods on a class, not reducers + action creators; auto-tracking replaces hand-written selectors
You need a strict, serializable action log as the center of your architecture, or large-team conventions built around RTK
Zustand
No-provider, hook-first store; minimal API
State 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 hook
You want the smallest possible store with no class ceremony and are happy writing selectors per subscription
Reactivity 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 tracking
You want deep observable graphs with computed/reaction and prefer mutate-in-place ergonomics
React Context
Tree-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 disposable
The value is genuinely tree-scoped config (theme, locale, a request) that should follow the component tree, change rarely, and never needs disposal
Jotai / Recoil
Fine-grained, atom-like subscription granularity
Granularity comes from which paths you read in one state object, not from composing many atoms; one cohesive class instead of a graph of atoms
You 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.
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.
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.