Skip to content

React Getting Started

BlaC’s mental model is one line: state lives in a class (a Cubit), and useBloc connects a component to it, re-rendering only when the data that component actually reads changes. No providers to wire up, no reducers, no selectors required. For the full reasoning behind that design, see Mental Model.

This page gets you from install to a working counter and todo list. For the framework-agnostic introduction to Cubits, see Getting Started; this page focuses on the React binding.

Terminal window
pnpm add @blac/core @blac/react

Requires React 18+ and TypeScript is recommended.

Two steps: define a Cubit that holds state and exposes methods to change it, then connect a component to it with useBloc. The snippet below is complete and copy-pasteable.

import { Cubit } from '@blac/core';
import { useBloc } from '@blac/react';
class CounterCubit extends Cubit<{ count: number }> {
constructor() {
super({ count: 0 }); // initial state goes to super()
}
// Arrow-function fields keep `this` bound, so you can pass them straight
// to onClick without wrapping. (See "common mistakes" below.)
increment = () => this.emit({ count: this.state.count + 1 });
decrement = () => this.update((s) => ({ count: s.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>
);
}

useBloc returns a tuple [state, bloc]:

  • state — the current state, tracked for re-renders. Reading state.count here subscribes this component to count only; if the state grows other fields, changing them won’t re-render Counter.
  • bloc — the Cubit instance. Call its methods to drive state changes (counter.increment()).

There is no <Provider> to add at the root and nothing to register. The first component to call useBloc(CounterCubit) creates the instance; the last one to unmount disposes it.

A Cubit exposes three mutation methods. Most code uses emit; reach for the others when they read better:

MethodShapeUse when
emitemit(nextState)You’re replacing the whole state object.
updateupdate((prev) => nextState)The next state derives from the previous one.
patchpatch(partial)You want to deep-merge a few fields and leave the rest.
Where does the state live?

The instance is held in a global registry and reference-counted by useBloc. The “what just happened” details — acquire on mount, release on unmount, automatic disposal at zero refs — are covered in Instance Management, and the reasoning behind the design is in Mental Model.

Auto-tracking is on by default and needs no configuration. The only knob is per call: pass a select function to useBloc to choose exactly what drives re-renders instead.

ModeHow to enableRe-renders when
Auto-trackingDefault (no select)A state path you read during render changes
Manual selectselect: (s) => [s.count]A selected value changes (per-index Object.is)

By default all components calling useBloc(SameCubit) share one instance. You can scope instances by args values or per mount:

ModeHow to enableBehavior
SharedDefault (no args)All components share one instance
Per-args (default hash){ args: { id } }Each distinct args value gets its own instance
Per-args (custom key){ args: { id } } + static key = (a) => a.idOnly the keyed field forks instances; others ride along
Per-mount{ args: { _id: useId() } } + static key = (a) => a._idEach component mount gets a private instance

See Passing Inputs for the full identity model and precedence.

Once the counter works, the natural next steps are reading state efficiently and giving blocs input: