Skip to content

Coming from flutter_bloc

BlaC is a direct descendant of flutterbloc. The name is not a coincidence: _Business Logic Components is the Flutter pattern, and BlaC carries the same core idea — a class owns a slice of logic and emits state — from Dart into TypeScript. If you have shipped flutter_bloc apps, most of the mental model travels straight across. What changes is idiomatic Dart vs idiomatic TypeScript, and the React binding layer.

flutter_bloc termBlaC termNotes
Cubit<S>Cubit<S>Same name, same idea: extend, set initial state, expose methods
Bloc<E, S>Cubit<S> (no event class)BlaC drops the Bloc event layer; methods are the events
emit(state)this.emit(state) / this.patch / this.updateSame concept; BlaC adds patch (deep-merge) and update (derive)
BlocProviderRegistry (automatic)No provider tree — instances are shared via a ref-counted registry
BlocBuilderuseBloc hookReturns [state, bloc]; re-renders are auto-tracked, not buildWhen
BlocListeneruseBloc + onMount / watchSide-effects in an effect or a watch subscription outside React
BlocConsumeruseBloc (both roles in one hook)State read + method call in the same component
MultiBlocProviderNothing — just call multiple useBloc callsNo setup needed; each hook acquires its own instance
context.read<T>()useBloc(T) (or borrow / ensure outside React)Registry lookup by class, not Flutter BuildContext
context.watch<T>()useBloc(T) (auto-tracks what you read)Every useBloc is implicitly a “watch”
buildWhenselect option on useBlocselect: (s, b) => [s.field] — re-render only when array changes
RepositoryProviderCubit with @blac({ keepAlive: true })Or pass a service as a constructor arg; no separate “repository” type
HydratedBlocPersistence plugin (@blac/plugin-persist)Uses IndexedDB by default; swap adapter for React Native / other

The Bloc event layer does not exist in BlaC

Section titled “The Bloc event layer does not exist in BlaC”

flutter_bloc ships two classes: Cubit (methods you call) and Bloc (events you dispatch, handlers you register). BlaC only has Cubit. If you used flutter’s Bloc class, translate each on<Event> handler to a method:

// flutter_bloc — Bloc variant
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<IncrementEvent>((event, emit) => emit(state + 1));
on<DecrementEvent>((event, emit) => emit(state - 1));
}
}
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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they 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 });
CounterCubit.decrement: () => void
decrement
= () => 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 });
}

The method is the event. There is no event class, no dispatch, no add(). You call the method directly. This is identical to flutter_bloc’s Cubit half.

flutter_bloc

// cubit
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
}
// widget tree
class CounterPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => CounterCubit(),
child: const CounterView(),
);
}
}
class CounterView extends StatelessWidget {
const CounterView({super.key});
@override
Widget build(BuildContext context) {
final count = context.watch<CounterCubit>().state;
return Column(
children: [
Text('$count'),
ElevatedButton(
onPressed: () => context.read<CounterCubit>().increment(),
child: const Text('+'),
),
ElevatedButton(
onPressed: () => context.read<CounterCubit>().decrement(),
child: const Text('-'),
),
],
);
}
}

BlaC

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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they 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 });
CounterCubit.decrement: () => void
decrement
= () => 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 (
<div>
<p>{state.count}</p>
<button onClick={counter.increment}>+</button>
<button onClick={counter.decrement}>-</button>
</div>
);
}

What changed:

  • No BlocProvider wrapping the tree — the registry handles instance sharing.
  • No context.watch / context.read split — useBloc is both.
  • State is an object, not a raw int (TypeScript patterns favour typed objects).
  • buildWhen is not needed; BlaC auto-tracks which fields the component reads.

In flutter_bloc you place a BlocProvider in the widget tree so descendants can look up the Cubit via context. BlaC has no equivalent provider. The registry is a global singleton keyed on the class itself. Two calls to useBloc(CounterCubit) always return the same instance, regardless of where in the React tree they sit.

For scoped instances — an editor with per-document state — make the scoping value part of args instead of using a wrapping provider:

class EditorCubit extends Cubit<EditorState, { docId: string }> {
static key = (a: EditorCubit['args']) => a.docId;
}
function Editor({ docId }: { docId: string }) {
const [state, editor] = useBloc(EditorCubit, { args: { docId } });
// ...
}

Each docId gets an independent EditorCubit. When all components using that docId unmount, the instance is disposed automatically (same ref-counting lifecycle as flutter_bloc’s BlocProvider create + close).

flutter_bloc’s BlocBuilder drives a rebuild with buildWhen; BlocConsumer adds a listener for side effects. BlaC rolls both into one hook:

// flutter_bloc
BlocConsumer<WeatherCubit, WeatherState>(
listenWhen: (prev, curr) => prev.status != curr.status,
listener: (context, state) {
if (state.status == WeatherStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(/*...*/);
}
},
buildWhen: (prev, curr) => prev.temperature != curr.temperature,
builder: (context, state) => Text('${state.temperature}°'),
)
import { useBloc } from '@blac/react';
import { useEffect } from 'react';
function WeatherDisplay() {
const [state] = useBloc(WeatherCubit, {
// re-render only when temperature changes
select: (s) => [s.temperature],
onMount: (bloc) => {
// side effects on mount
},
});
useEffect(() => {
if (state.status === 'failure') {
showSnackBar('Weather load failed');
}
}, [state.status]);
return <p>{state.temperature}°</p>;
}

The select option replaces buildWhen (re-render when the returned array changes). Status side-effects go in a plain useEffect. For global, non-React listeners, use watch:

const
const unwatch: () => void
unwatch
=
watch<typeof WeatherCubit>(bloc: typeof WeatherCubit | BlocRef<typeof WeatherCubit>, callback: (bloc: WeatherCubit) => void | unique symbol): () => void (+1 overload)
watch
(
class WeatherCubit
WeatherCubit
, (
bloc: WeatherCubit
bloc
) => {
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
('new temp:',
bloc: WeatherCubit
bloc
.
StructuralContainer<{ status: string; temperature: number; }>.state: {
status: string;
temperature: number;
}
state
.
temperature: number
temperature
);
});
// call unwatch() to stop

flutter_bloc commonly uses plain Dart primitives as state (Cubit<int>, Cubit<bool>) or sealed classes. BlaC state is always an object literal in practice — TypeScript works best with typed record shapes, and patch only makes sense on an object:

// Prefer a typed object over a raw primitive
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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they 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 });
}

For discriminated-union state (the Dart sealed-class pattern), use a TypeScript union type. The view switches on state.status and TypeScript narrows each branch — same ergonomics as Dart when:

type
type UserState = {
status: "idle";
} | {
status: "loading";
} | {
status: "success";
user: {
id: string;
name: string;
};
} | {
status: "error";
message: string;
}
UserState
=
| {
status: "idle"
status
: 'idle' }
| {
status: "loading"
status
: 'loading' }
| {
status: "success"
status
: 'success';
user: {
id: string;
name: string;
}
user
: {
id: string
id
: string;
name: string
name
: string } }
| {
status: "error"
status
: 'error';
message: string
message
: string };
class
class UserCubit
UserCubit
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 (see A2 audit).

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). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
<
type UserState = {
status: "idle";
} | {
status: "loading";
} | {
status: "success";
user: {
id: string;
name: string;
};
} | {
status: "error";
message: string;
}
UserState
> {
constructor() {
super({
status: "idle"
status
: 'idle' });
}
UserCubit.load: (id: string) => Promise<void>
load
= async (
id: string
id
: string) => {
this.
StateContainer<UserState, void, Record<string, never>>.emit(next: UserState): void
emit
({
status: "loading"
status
: 'loading' });
try {
const
const user: {
id: string;
name: string;
}
user
= await
const api: {
fetchUser(id: string): Promise<{
id: string;
name: string;
}>;
}
api
.
function fetchUser(id: string): Promise<{
id: string;
name: string;
}>
fetchUser
(
id: string
id
);
this.
StateContainer<UserState, void, Record<string, never>>.emit(next: UserState): void
emit
({
status: "success"
status
: 'success',
user: {
id: string;
name: string;
}
user
});
} catch (
function (local var) e: unknown
e
) {
this.
StateContainer<UserState, void, Record<string, never>>.emit(next: UserState): void
emit
({
status: "error"
status
: 'error',
message: string
message
:
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(
function (local var) e: unknown
e
) });
}
};
}

Persistence: HydratedBloc → persist plugin

Section titled “Persistence: HydratedBloc → persist plugin”

flutter_bloc ships HydratedBloc/HydratedCubit for local persistence. BlaC has a first-party plugin:

import { createIndexedDbPersistPlugin } from '@blac/plugin-persist';
import { getPluginManager } from '@blac/core';
const persist = createIndexedDbPersistPlugin();
getPluginManager().install(persist);

The persist plugin saves and restores state via IndexedDB. For React Native, swap the storage adapter (the plugin ships an interface; pass an AsyncStorage-backed adapter). See Persistence.

flutter_blocBlaC
Tree = provider scoping mechanismRegistry = global, class-keyed, ref-counted
context carries bloc referencesImport the class; the registry finds the instance
BlocBuilder re-builds on buildWhenuseBloc re-renders on auto-tracked read paths
BlocListener for side effectsuseEffect on state values, or watch outside React
close() called by BlocProviderrelease() / ref-count-zero triggers automatic disposal
Event objects for Bloc classMethod calls — no dispatch, no add()

If you used Cubit in Flutter (not the full Bloc event layer), migrating to BlaC is mostly syntax translation. If you used Bloc events, collapse each on<Event> handler into a method.

  • Core Concepts — state containers, registry, dependency tracking
  • Comparison — BlaC vs Zustand vs Jotai, including the flutter_bloc lineage
  • useBloc — full hook reference with args, select, onMount
  • Async — async methods, status unions, cancellation
  • Persistence — the persist plugin and storage adapters