@blac/react
v2.0.19
Published
React bindings for BlaC — useBloc hook with automatic re-render optimization
Maintainers
Readme
@blac/react
React bindings for BlaC — useBloc hook with proxy-based automatic re-render optimization.
[!WARNING] BlaC v2 is in pre-release (beta). While in beta, breaking API changes may ship in patch releases without a major version bump. Pin an exact version and check the changelog before upgrading. Strict semver resumes once v2 is officially out of beta.
Installation
pnpm add @blac/react @blac/coreRequires React 18+.
Quick Start
import { Cubit } from '@blac/core';
import { useBloc } from '@blac/react';
class CounterCubit extends Cubit<{ count: number }> {
constructor() {
super({ count: 0 });
}
increment = () => this.emit({ count: this.state.count + 1 });
decrement = () => this.emit({ count: this.state.count - 1 });
}
function Counter() {
const [state, counter] = useBloc(CounterCubit);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={counter.increment}>+</button>
<button onClick={counter.decrement}>-</button>
</div>
);
}Reactivity model
BlaC follows one rule:
A component becomes reactive only to the paths it reads during its own render.
useBloc returns a proxy that records which paths you read; the component
re-renders only when one of those paths changes. Reads outside render — effects,
event handlers, setTimeout, await — record nothing.
Props are snapshots — untracked()
A tracking proxy stays bound to the component that created it. If a parent passes a proxied value to a child and the child reads from it, that read is attributed to the parent — so the parent over-subscribes and re-renders for paths only the child uses.
Pass the value through untracked() to hand the child a detached snapshot instead:
import { useBloc, untracked } from '@blac/react';
function List() {
const [state] = useBloc(TodoBloc);
// The parent reads each id (for keys). Rows read `.label` etc. off the
// snapshot, which is detached — those reads are NOT attributed to List.
return (
<ul>
{state.items.map((item) => (
<Row key={item.id} item={untracked(item)} />
))}
</ul>
);
}The trade-off is intentional and React-like: a component that receives an
untracked() value is not reactive to it — a change triggers nothing, memo or
not. If no component reads a path during its own render, a change to that path
re-renders nothing. To react to data, a component must be a consumer itself (call
its own useBloc). Nobody up the tree re-renders for a path unless they read it
themselves.
Making this automatic — per-component read scoping so
untracked()isn't needed by hand — is a planned compiler/Babel plugin. Seeplans/blac-ambient-tracking/design.md.
useBloc
const [state, bloc, ref] = useBloc(MyBloc, options?);Returns: [state, bloc, ref]
state— current state snapshot (proxied for auto-tracking)bloc— bloc instance for calling methodsref— internal component ref (advanced usage)
The Three Input Lanes
BlaC blocs receive external data through three distinct channels:
| Lane | Purpose | Keying | Lifetime | Example |
| ---------- | ---------------------------------------------- | ----------------------------------------- | ------------------------- | ----------------------------------------- |
| args | Typed creation data; derives instance identity | Yes (structural hash or static key) | Once at init() | userId, endpoint |
| deps | Non-serializable refs, callbacks, handles | Never | Live, per-consumer merged | ref, onComplete callback, emblaApi |
| events | Values that change over time or are late-bound | N/A | Called from effects | cubit.slidesChanged(v) from useEffect |
args: Typed Construction Data
When a bloc declares Args, you must pass them at the call site. Args are fed to the bloc's init(args) method before the first state snapshot, and they derive the instance identity by default.
import { Cubit } from '@blac/core';
import { useBloc } from '@blac/react';
class UserCardCubit extends Cubit<UserCardState, { userId: string }> {
// Constructor is zero-arg; framework calls init(args) before first snapshot
init(args: { userId: string }) {
this.userId = args.userId;
void this.loadUser(args.userId);
}
}
function UserCard({ userId }: { userId: string }) {
// args is required and type-checked when Args != void
const [state, cubit] = useBloc(UserCardCubit, { args: { userId } });
return <div>{state.user?.name}</div>;
}Key properties:
- Required when declared — omitting
argsor passing the wrong shape is a type error. - Drives identity — different
args⇒ different instance. Sameargs⇒ same instance (dev-warn if args mismatch on same-keyed second call). - Serializable only — non-serializable values (refs, callbacks) belong in the
depslane (below). - Per-component private instances — embed a per-mount unique ID inside
args(using React'suseId()) to give each mount its own instance, disposed on unmount:
const id = useId();
const [state, cubit] = useBloc(FormCubit, { args: { ...options, _id: id } });deps: Non-Serializable Refs and Callbacks
Use deps to inject refs, stable callbacks, and long-lived controller handles. Unlike args, deps are:
- Never keying — different refs don't fork the instance
- Per-consumer merged — each component contributes its own slice
- Read lazily — accessed via
this.deps.xwhen needed, may be undefined - Live — can change over time
deps is not a useBloc option. A component contributes its slice from a mount effect, using APPLY_DEPS / REMOVE_DEPS_OWNER from @blac/core (marked @internal today; a friendlier wrapper may land later):
import { useEffect, useId, useRef } from 'react';
import { APPLY_DEPS, REMOVE_DEPS_OWNER } from '@blac/core';
import { useBloc } from '@blac/react';
const inputRef = useRef<HTMLInputElement>(null);
const ownerId = useId();
const [state, cubit] = useBloc(FileUploadCubit, { args: { endpoint } });
useEffect(() => {
cubit[APPLY_DEPS](ownerId, { inputRef });
return () => cubit[REMOVE_DEPS_OWNER](ownerId);
}, [cubit, inputRef, ownerId]);The bloc reads them lazily and guards for absence:
class FileUploadCubit extends Cubit<
UploadState,
{ endpoint: string },
{
inputRef?: RefObject<HTMLInputElement>;
onComplete?: () => void;
}
> {
async upload() {
this.deps.inputRef?.current?.click?.();
// ... perform upload ...
this.deps.onComplete?.();
}
}Multi-consumer merge: when multiple components provide the same cubit with different deps, their keys are shallow-merged:
// Component A owns inputRef
cubitA[APPLY_DEPS](ownerIdA, { inputRef });
// Component B owns onSubmit
cubitB[APPLY_DEPS](ownerIdB, { onSubmit });
// cubit.deps === { inputRef, onSubmit } (merged from both consumers)Avoid raw callbacks — the callback staleness gotcha. Prefer:
- Don't inject callbacks — invert (best): expose state and let React call the fresh callback in its own effect.
- Stabilize at source — wrap in
useCallback. - As an event — push the callback via a bloc method called from your effect.
events: Methods Called from Effects
For data that changes over the instance's life (a slides array, a theme selection), call an ordinary bloc method from an effect — not a provider-owned input. This keeps ownership explicit and eliminates render-time mutation.
class CarouselCubit extends Cubit<CarouselState> {
slidesChanged(slides: Slide[]) {
this.patch({ slides, total: slides.length });
}
}function Carousel({ slides }: { slides: Slide[] }) {
const [state, cubit] = useBloc(CarouselCubit, { args: { id } });
// ONE component owns syncing this live value
useEffect(() => {
cubit.slidesChanged(slides);
}, [cubit, slides]);
return /* ... */;
}Convention: one component owns syncing any given live value. Two components calling the same event from both their effects on the same shared instance is a design smell (and rare, because keyed args usually route multi-consumer cases to distinct instances).
Tracking Modes
Auto-tracking (default): Only re-renders when accessed properties change.
const [state] = useBloc(UserBloc);
return <h1>{state.name}</h1>; // only re-renders when name changesManual dependencies (select): Explicit dependency array (disables auto-tracking).
const [state] = useBloc(CounterCubit, {
select: (state) => [state.count],
});Options
| Option | Type | Description |
| ----------- | ---------------------------- | -------------------------------------------------------------------------------- |
| args | Args type | Required when bloc declares Args != void; forbidden when void |
| select | (state, bloc) => unknown[] | Manual dependency selector (renamed from dependencies); disables auto-tracking |
| onMount | (bloc) => void | Called when component mounts |
| onUnmount | (bloc) => void | Called when component unmounts |
Auto-tracking is always on when
selectis omitted — it is not a configurable option.deps,autoInstance, andinstanceIdare notuseBlocoptions: wire deps from a mount effect (APPLY_DEPS/REMOVE_DEPS_OWNER); for per-mount private instances, embed a stable unique ID inargs(e.g.{ args: { _id: useId() } }).
Identity and Keying
Instance identity is resolved in precedence order:
<BlocProvider>context id — inherited from an ancestor providerstatic key(args)→ structural hash ofargs— default when the bloc declaresArgs(static keywins if defined; otherwise a stable hash of allargs)'default'— singleton fallback
For a per-mount private instance, embed a stable unique ID inside args so each mount hashes to a distinct key:
const id = useId();
useBloc(FormCubit, { args: { ...options, _id: id } });Blocs declare explicit identity via a static class property:
class DocumentCubit extends Cubit<
DocState,
{ docId: string; readonly: boolean }
> {
static key = (args) => args.docId;
// Identity is docId; readonly config rides along but doesn't fork instances
}Instance Sharing and Lifecycle
By default, all components using useBloc(MyBloc) with the same identity share one instance. For per-component private instances, embed a unique ID in args so each mount derives a distinct key:
// All users with userId=123 share one instance
useBloc(UserCardCubit, { args: { userId: 123 } });
// Each component mount gets its own instance, disposed on unmount
const id = useId();
useBloc(FormCubit, { args: { ...options, _id: id } });
// Explicit stable key (escape hatch for non-derivable identity)
useBloc(EditorCubit, { args: { _id: 'editor-1' } });Breaking Changes (v2)
dependenciesoption renamed toselect— avoids confusion with thedeps(non-serializable handles) lane.autoTrack,autoInstance,instanceId, anddepsare no longeruseBlocoptions — auto-tracking is always on (opt out per-consumer withselect); per-mount private instances embed a unique ID inargs; deps are wired from a mount effect viaAPPLY_DEPS/REMOVE_DEPS_OWNER.- Zero-arg constructor +
init(args)lifecycle — all blocs now usenew Type()with no constructor args. Blocs that declareArgsreceive them viainit(args)called by the framework before the first state snapshot. argsis required when declared, forbidden when void — enforced by the type system; no runtime guard needed.
Configuration
import { configureBlacReact } from '@blac/react';
// Configuration is currently empty; the tracking model is fixed and not configurable.
configureBlacReact({});Testing
import { renderWithBloc } from '@blac/react/testing';See the testing docs for details.
License
MIT
