Skip to content

What is BlaC?

BlaC (Business Logic Components) is a TypeScript state management library for React. It separates business logic into class-based state containers — Cubits — that are type-safe, testable, and automatically optimized for minimal re-renders.

A first taste — the whole loop in one screen:

import
(alias) namespace React
import React
React
from 'react';
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';
import {
function useBloc<T extends StateContainerConstructor = StateContainerConstructor>(BlocClass: T, options?: UseBlocOptions<T>): UseBlocReturn<T, ExtractState<T>>

React hook that connects a component to a state container with automatic re-render on state changes.

Two tracking modes:

  • Auto-tracking (default): the returned state value is a proxy that records read paths during render. The component re-renders when any recorded path changes. Backed by @dirtytalk/structural's

trackRender

  • the container's path-scoped DirtyChannel.
  • Manual select: pass options.select to opt out of auto-tracking. The hook re-renders only when the returned array's elements change (per-index Object.is).

Lifecycle:

  • The bloc is acquired from the registry on mount and released on unmount. The instance key is derived from options.args (own args), then the surrounding

BlocProvider

context args for this bloc, then the default key (no args).

  • options.onMount fires after the bloc is acquired; options.onUnmount fires before the registry releases its ref, so the bloc is still alive when the callback runs.

Per-mount private instance:

const id = useId();
const [state, bloc] = useBloc(MyBloc, { args: { _id: id } });

@templateT - The state container constructor type (inferred from BlocClass)

@paramBlocClass - The state container class to connect to

@paramoptions - Configuration options

@returnsTuple of [state, bloc, ref]

@example

Basic usage

const [state, bloc] = useBloc(MyBloc);

@example

Manual select

const [state, bloc] = useBloc(MyBloc, {
select: (state) => [state.count],
});

@example

Args-based shared instance

const [state, bloc] = useBloc(UserBloc, { args: { userId: 'alice' } });

useBloc
} from '@blac/react';
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 });
}
function
function Counter(): JSX.Element
Counter
() {
const [
const state: Readonly<{
count: number;
}>
state
,
const counter: InstanceReadonlyState<typeof CounterCubit>
counter
] =
useBloc<typeof CounterCubit>(BlocClass: typeof CounterCubit, options?: UseBlocOptions<typeof CounterCubit> | undefined): UseBlocReturn<typeof CounterCubit, Readonly<{
count: number;
}>>

React hook that connects a component to a state container with automatic re-render on state changes.

Two tracking modes:

  • Auto-tracking (default): the returned state value is a proxy that records read paths during render. The component re-renders when any recorded path changes. Backed by @dirtytalk/structural's

trackRender

  • the container's path-scoped DirtyChannel.
  • Manual select: pass options.select to opt out of auto-tracking. The hook re-renders only when the returned array's elements change (per-index Object.is).

Lifecycle:

  • The bloc is acquired from the registry on mount and released on unmount. The instance key is derived from options.args (own args), then the surrounding

BlocProvider

context args for this bloc, then the default key (no args).

  • options.onMount fires after the bloc is acquired; options.onUnmount fires before the registry releases its ref, so the bloc is still alive when the callback runs.

Per-mount private instance:

const id = useId();
const [state, bloc] = useBloc(MyBloc, { args: { _id: id } });

@templateT - The state container constructor type (inferred from BlocClass)

@paramBlocClass - The state container class to connect to

@paramoptions - Configuration options

@returnsTuple of [state, bloc, ref]

@example

Basic usage

const [state, bloc] = useBloc(MyBloc);

@example

Manual select

const [state, bloc] = useBloc(MyBloc, {
select: (state) => [state.count],
});

@example

Args-based shared instance

const [state, bloc] = useBloc(UserBloc, { args: { userId: 'alice' } });

useBloc
(
class CounterCubit
CounterCubit
);
return <
React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
button
React.DOMAttributes<HTMLButtonElement>.onClick?: React.MouseEventHandler<HTMLButtonElement> | undefined
onClick
={
const counter: InstanceReadonlyState<typeof CounterCubit>
counter
.
increment: () => void
increment
}>Count: {
const state: Readonly<{
count: number;
}>
state
.
count: number
count
}</
React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
button
>;
}

No store setup, no provider, no reducer, no selector. The class holds the logic; the hook connects it; re-renders are tracked automatically.

That snippet isn’t a screenshot — here it is running, the real @blac/react hook driving a real Cubit. The badge counts how many times the component has actually rendered:

Counter — live blac island
0renders1

Most state management libraries force you to choose between simplicity and power. Simple hooks-based solutions scatter logic across components. Powerful libraries require boilerplate, providers, and context wrappers.

BlaC takes a different approach:

  • State logic lives in classes, not components. Define your state shape and mutations in a Cubit class. Components just read state and call methods.
  • No providers or context wrappers. Import your class, call useBloc(MyClass), and you’re connected. The registry handles instance creation and sharing automatically.
  • Re-renders are precise by default. Auto-tracking proxies detect which state properties your component reads during render. Only changes to those properties trigger re-renders.
  • Lifecycle is declarative. Instances are shared by default. Use args (with a static key) for named per-component instances or @blac({ keepAlive: true }) for persistent singletons.
  • Built for TypeScript. State types flow from your class definition through the hook return value with zero type annotations needed.

Two components share one bloc — each reads a different field. Increment the count or edit the label: only the component reading the changed field re-renders.

Re-render isolation — live demo

Two components share one bloc. Each reads a different field. Change one — only its reader re-renders.

CountReaderrenders1

reads: state.count

0
LabelReaderrenders1

reads: state.label

BlaC has two layers:

┌─────────────────────────────┐
│ React useBloc hook │ Framework-specific binding,
│ BlocProvider │ path-scoped channel subscriptions
├─────────────────────────────┤
│ Core Cubit, │ State containers, registry,
│ Registry, │ plugins, watch, path-based
│ Plugins │ dirty tracking
└─────────────────────────────┘

Core (@blac/core) provides state containers, a global registry with ref counting, a plugin system, and utilities like watch. Proxy-based dependency tracking is built in — no separate adapter package is needed.

React (@blac/react) provides the useBloc hook. It subscribes each component to the bloc’s path-scoped channel and re-renders through React’s normal update path when a tracked read path changes. The optional BlocProvider shown above scopes default args to a subtree — most apps never need it (see useBloc for when it helps).

Under the hood: the DirtyTalk engine family

The “what changed, who cares, when do we tell them” machinery — path-based dirty tracking, the render-time proxy, microtask-batched flushing — lives in a lower-level, framework-agnostic family of packages called DirtyTalk. BlaC’s StateContainer extends @dirtytalk/structural’s container; you never need to touch it directly, but it’s there if you want to understand the foundation.

BlaC works best when:

  • You have complex state logic that benefits from being in a class (validation, derived state, async operations)
  • Multiple components need to share state without prop drilling or context providers
  • You want testable business logic that can run without React
  • You value TypeScript inference and want the compiler to catch state errors
  • Mental Model — how auto-tracking, the registry, and batching actually work
  • Quick Start — go from install to working component
  • Glossary — one-line definitions for every term used here