Skip to content

Coming from Redux (Toolkit)

Redux and BlaC share the same design principle: a single source of truth per concern, immutable state updates, and first-party DevTools support. The difference is in the mechanism. Redux routes every mutation through a dispatcher and a reducer; BlaC routes it through a method on a class. The result is less indirection, less boilerplate, and auto-tracked re-renders — but the tradeoff is giving up Redux’s strict, serializable action log.

If you use Redux Toolkit today, this page maps each RTK primitive to its BlaC equivalent and walks through a full side-by-side port.

Redux / RTK termBlaC termNotes
createSlice({ name, ... })class MyCubit extends Cubit<S>The class is the slice: state type, initial state, and mutations in one
initialStatesuper(initialState) in the constructorPassed to the parent class
reducers: { action: fn }Method on the classaction(payload) is the combined action-creator + reducer
createAsyncThunkasync method on the classNo thunk factory; just async method = async () => { ... }
extraReducers / builderAdditional methods or onSystemEventNo separate builder step; add more methods to the class
dispatch(action())cubit.method(args) / bloc.method(args)Call the method directly; no dispatcher
useSelector((s) => s.slice.x)useBloc(MyCubit) reading state.xNo selector written; the read during render is the subscription
useDispatch()Second element of useBloc tupleconst [state, cubit] = useBloc(MyCubit); cubit.method()
configureStore({ reducer })Registry (automatic)No store setup; instances live in the global ref-counted registry
Provider wrapping the appNothing — registry is implicitNo provider needed
createEntityAdapterClass with typed state + methodsModel a collection as items: Record<id, T> in the state object
RTK QueryAsync method + status unionBlaC does not ship a query layer; see Async for the pattern
Redux DevTools@blac/devtools-connect pluginFirst-party; inspects every Cubit, time-travel, state diff
MiddlewarePlugins (@blac/devtools-connect, @blac/plugin-persist, …)Installed once globally; observe all Cubits

The dispatch/reducer indirection does not exist in BlaC

Section titled “The dispatch/reducer indirection does not exist in BlaC”

RTK’s slice defines reducers keyed by action type; dispatch routes incoming action objects to the matching reducer. BlaC removes the intermediary. The action name is the method name; the reducer is the method body; and calling the method is the dispatch. One step instead of three.

// RTK — three artifacts per mutation
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => {
state.value += 1;
}, // reducer
incrementByAmount: (state, action) => {
state.value += action.payload; // reducer + payload type
},
},
});
export const { increment, incrementByAmount } = counterSlice.actions; // action creators
export default counterSlice.reducer;
// BlaC — one artifact
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
<{
value: number
value
: number }> {
constructor() {
super({
value: number
value
: 0 });
}
CounterCubit.increment: () => void
increment
= () => this.
StateContainer<{ value: number; }, void, Record<string, never>>.emit(next: {
value: number;
}): void
emit
({
value: number
value
: this.
StructuralContainer<{ value: number; }>.state: {
value: number;
}
state
.
value: number
value
+ 1 });
CounterCubit.incrementByAmount: (amount: number) => void
incrementByAmount
= (
amount: number
amount
: number) =>
this.
StateContainer<{ value: number; }, void, Record<string, never>>.emit(next: {
value: number;
}): void
emit
({
value: number
value
: this.
StructuralContainer<{ value: number; }>.state: {
value: number;
}
state
.
value: number
value
+
amount: number
amount
});
}

No separate file for actions. No export const { ... }. No reducer export to wire into a store.

RTK ships Immer so reducers can write state.value += 1 (mutable syntax compiled to immutable updates). BlaC state is always immutable: emit(next) and update(fn) replace the whole state object, and patch(partial) deep-merges a partial. You never mutate this.state in place:

// RTK would let you write: state.items.push(item)
// BlaC — always return a new value
class
class ListCubit
ListCubit
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
<{
items: Item[]
items
:
interface Item
Item
[] }> {
constructor() {
super({
items: Item[]
items
: [] });
}
ListCubit.add: (item: Item) => void
add
= (
item: Item
item
:
interface Item
Item
) => this.
StateContainer<{ items: Item[]; }, void, Record<string, never>>.patch(partial: {
items?: readonly {
id?: string | undefined;
name?: string | undefined;
}[] | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
items?: readonly {
id?: string | undefined;
name?: string | undefined;
}[] | undefined
items
: [...this.
StructuralContainer<{ items: Item[]; }>.state: {
items: Item[];
}
state
.
items: Item[]
items
,
item: Item
item
] });
ListCubit.remove: (id: string) => void
remove
= (
id: string
id
: string) =>
this.
StateContainer<{ items: Item[]; }, void, Record<string, never>>.patch(partial: {
items?: readonly {
id?: string | undefined;
name?: string | undefined;
}[] | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
items?: readonly {
id?: string | undefined;
name?: string | undefined;
}[] | undefined
items
: this.
StructuralContainer<{ items: Item[]; }>.state: {
items: Item[];
}
state
.
items: Item[]
items
.
Array<Item>.filter(predicate: (value: Item, index: number, array: Item[]) => unknown, thisArg?: any): Item[] (+1 overload)

Returns the elements of an array that meet the condition specified in a callback function.

@parampredicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.

@paramthisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.

filter
((
i: Item
i
) =>
i: Item
i
.
Item.id: string
id
!==
id: string
id
) });
}

patch deep-merges, so you only mention the key you are changing. emit and update replace the whole state — list every key, or spread the previous state.

Redux Toolkit

// slice
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface TodoItem {
id: number;
text: string;
completed: boolean;
}
interface TodoState {
items: TodoItem[];
nextId: number;
}
const todoSlice = createSlice({
name: 'todos',
initialState: { items: [], nextId: 1 } as TodoState,
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
state.items.push({
id: state.nextId++,
text: action.payload,
completed: false,
});
},
toggleTodo: (state, action: PayloadAction<number>) => {
const todo = state.items.find((t) => t.id === action.payload);
if (todo) todo.completed = !todo.completed;
},
removeTodo: (state, action: PayloadAction<number>) => {
state.items = state.items.filter((t) => t.id !== action.payload);
},
},
});
export const { addTodo, toggleTodo, removeTodo } = todoSlice.actions;
export default todoSlice.reducer;
// component
import { useSelector, useDispatch } from 'react-redux';
function TodoList() {
const items = useSelector((s: RootState) => s.todos.items);
const dispatch = useDispatch();
return (
<ul>
{items.map((t) => (
<li key={t.id}>
<input
type="checkbox"
checked={t.completed}
onChange={() => dispatch(toggleTodo(t.id))}
/>
{t.text}
<button onClick={() => dispatch(removeTodo(t.id))}>x</button>
</li>
))}
</ul>
);
}

BlaC

interface
interface TodoItem
TodoItem
{
TodoItem.id: number
id
: number;
TodoItem.text: string
text
: string;
TodoItem.completed: boolean
completed
: boolean;
}
class
class TodoCubit
TodoCubit
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
<{
items: TodoItem[]
items
:
interface TodoItem
TodoItem
[];
nextId: number
nextId
: number }> {
constructor() {
super({
items: TodoItem[]
items
: [],
nextId: number
nextId
: 1 });
}
TodoCubit.addTodo: (text: string) => void
addTodo
= (
text: string
text
: string) => {
const {
const items: TodoItem[]
items
,
const nextId: number
nextId
} = this.
StructuralContainer<{ items: TodoItem[]; nextId: number; }>.state: {
items: TodoItem[];
nextId: number;
}
state
;
this.
StateContainer<{ items: TodoItem[]; nextId: number; }, void, Record<string, never>>.emit(next: {
items: TodoItem[];
nextId: number;
}): void
emit
({
items: TodoItem[]
items
: [...
const items: TodoItem[]
items
, {
TodoItem.id: number
id
:
const nextId: number
nextId
,
TodoItem.text: string
text
,
TodoItem.completed: boolean
completed
: false }],
nextId: number
nextId
:
const nextId: number
nextId
+ 1,
});
};
TodoCubit.toggleTodo: (id: number) => void
toggleTodo
= (
id: number
id
: number) =>
this.
StateContainer<{ items: TodoItem[]; nextId: number; }, void, Record<string, never>>.patch(partial: {
items?: readonly {
id?: number | undefined;
text?: string | undefined;
completed?: boolean | undefined;
}[] | undefined;
nextId?: number | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
items?: readonly {
id?: number | undefined;
text?: string | undefined;
completed?: boolean | undefined;
}[] | undefined
items
: this.
StructuralContainer<{ items: TodoItem[]; nextId: number; }>.state: {
items: TodoItem[];
nextId: number;
}
state
.
items: TodoItem[]
items
.
Array<TodoItem>.map<TodoItem>(callbackfn: (value: TodoItem, index: number, array: TodoItem[]) => TodoItem, thisArg?: any): TodoItem[]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.

@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.

map
((
t: TodoItem
t
) =>
t: TodoItem
t
.
TodoItem.id: number
id
===
id: number
id
? { ...
t: TodoItem
t
,
TodoItem.completed: boolean
completed
: !
t: TodoItem
t
.
TodoItem.completed: boolean
completed
} :
t: TodoItem
t
,
),
});
TodoCubit.removeTodo: (id: number) => void
removeTodo
= (
id: number
id
: number) =>
this.
StateContainer<{ items: TodoItem[]; nextId: number; }, void, Record<string, never>>.patch(partial: {
items?: readonly {
id?: number | undefined;
text?: string | undefined;
completed?: boolean | undefined;
}[] | undefined;
nextId?: number | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
items?: readonly {
id?: number | undefined;
text?: string | undefined;
completed?: boolean | undefined;
}[] | undefined
items
: this.
StructuralContainer<{ items: TodoItem[]; nextId: number; }>.state: {
items: TodoItem[];
nextId: number;
}
state
.
items: TodoItem[]
items
.
Array<TodoItem>.filter(predicate: (value: TodoItem, index: number, array: TodoItem[]) => unknown, thisArg?: any): TodoItem[] (+1 overload)

Returns the elements of an array that meet the condition specified in a callback function.

@parampredicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.

@paramthisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.

filter
((
t: TodoItem
t
) =>
t: TodoItem
t
.
TodoItem.id: number
id
!==
id: number
id
) });
}
import { useBloc } from '@blac/react';
function TodoList() {
const [state, todos] = useBloc(TodoCubit);
return (
<ul>
{state.items.map((t) => (
<li key={t.id}>
<input
type="checkbox"
checked={t.completed}
onChange={() => todos.toggleTodo(t.id)}
/>
{t.text}
<button onClick={() => todos.removeTodo(t.id)}>x</button>
</li>
))}
</ul>
);
}

What changed:

  • No createSlice, no PayloadAction, no action-creator exports.
  • No configureStore, no Provider, no RootState type.
  • useSelector + useDispatch collapse into a single useBloc call.
  • Re-renders are auto-tracked: TodoList wakes only when items changes (not when nextId does).

RTK’s thunk factory adds a lifecycle (pending / fulfilled / rejected) dispatched as separate action objects. BlaC async is a plain async method that calls emit as it goes:

// RTK
export const fetchUser = createAsyncThunk('user/fetch', async (id: string) => {
const response = await api.fetchUser(id);
return response.data;
});
const userSlice = createSlice({
name: 'user',
initialState: { status: 'idle', user: null, error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'success';
state.user = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.status = 'error';
state.error = action.error.message ?? null;
});
},
});
type
type UserState = {
status: "idle";
} | {
status: "loading";
} | {
status: "success";
user: User;
} | {
status: "error";
message: string;
}
UserState
=
| {
status: "idle"
status
: 'idle' }
| {
status: "loading"
status
: 'loading' }
| {
status: "success"
status
: 'success';
user: User
user
:
interface User
User
}
| {
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: User;
} | {
status: "error";
message: string;
}
UserState
> {
private
UserCubit.requestId: number
requestId
= 0;
constructor() {
super({
status: "idle"
status
: 'idle' });
}
UserCubit.fetchUser: (id: string) => Promise<void>
fetchUser
= async (
id: string
id
: string) => {
const
const reqId: number
reqId
= ++this.
UserCubit.requestId: number
requestId
;
this.
StateContainer<UserState, void, Record<string, never>>.emit(next: UserState): void
emit
({
status: "loading"
status
: 'loading' });
try {
const
const user: User
user
= await
const api: {
fetchUser(id: string): Promise<User>;
}
api
.
function fetchUser(id: string): Promise<User>
fetchUser
(
id: string
id
);
if (
const reqId: number
reqId
!== this.
UserCubit.requestId: number
requestId
) return;
this.
StateContainer<UserState, void, Record<string, never>>.emit(next: UserState): void
emit
({
status: "success"
status
: 'success',
user: User
user
});
} catch (
function (local var) e: unknown
e
) {
if (
const reqId: number
reqId
!== this.
UserCubit.requestId: number
requestId
) return;
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
) });
}
};
}

The requestId guard replaces RTK’s thunk cancellation — a newer call wins and the older one drops its result. No AbortController needed for this pattern, though BlaC supports it too.

RTK encourages createSelector (Reselect) to derive and memoize values from the store:

// RTK + Reselect
export const selectTotal = createSelector(
(s: RootState) => s.cart.items,
(items) => items.reduce((sum, i) => sum + i.price * i.qty, 0),
);

BlaC derives values in a getter on the class. Auto-tracking records the getter’s underlying reads, so a component can read cart.total and stay subscribed to the source paths without a separate selector:

class
class CartCubit
CartCubit
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
<{
items: CartItem[]
items
:
interface CartItem
CartItem
[] }> {
constructor() {
super({
items: CartItem[]
items
: [] });
}
get
CartCubit.total: number
total
() {
return this.
StructuralContainer<{ items: CartItem[]; }>.state: {
items: CartItem[];
}
state
.
items: CartItem[]
items
.
Array<CartItem>.reduce<number>(callbackfn: (previousValue: number, currentValue: CartItem, currentIndex: number, array: CartItem[]) => number, initialValue: number): number (+2 overloads)

Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function.

@paramcallbackfn A function that accepts up to four arguments. The reduce method calls the callbackfn function one time for each element in the array.

@paraminitialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value.

reduce
((
sum: number
sum
,
i: CartItem
i
) =>
sum: number
sum
+
i: CartItem
i
.
CartItem.price: number
price
*
i: CartItem
i
.
CartItem.qty: number
qty
, 0);
}
}

A component that reads cart.total during render re-renders when the getter’s source paths change — no memoization layer, no Reselect import. Use select if the computed total itself should gate re-renders.

Redux DevTools is the standard time-travel inspector for Redux and RTK. BlaC ships @blac/devtools-connect as a first-party plugin:

import { getPluginManager } from '@blac/core';
import { createDevToolsBrowserPlugin } from '@blac/devtools-connect';
getPluginManager().install(createDevToolsBrowserPlugin(), {
environment: 'development',
});

The plugin shows every Cubit’s state changes, diffs, and method calls in the same Redux DevTools panel. State is diffed at the field level; you can step forward and backward through mutations.

RTK requires a configureStore call to wire reducers, and a Provider at the tree root:

// RTK setup
const store = configureStore({
reducer: { counter: counterSlice.reducer, todos: todoSlice.reducer },
});
function App() {
return (
<Provider store={store}>
<Counter />
<TodoList />
</Provider>
);
}

BlaC has no equivalent. The registry is global, implicit, and automatic. Components call useBloc and the registry creates instances on first use, shares them, and disposes them when the last consumer unmounts. No bootstrap, no provider tree.

Redux’s strict serializable action log is a genuine architectural choice, not just boilerplate. Reach for it when:

  • Your team needs a comprehensive audit trail of every state transition (e.g. regulated industries, complex undo/redo flows over many slices).
  • You have large-team conventions and tooling already built around RTK (code generators, lint rules, saga/observable middleware).
  • RTK Query is doing meaningful work for you (caching, deduplication, polling).

For most product apps where “I need shared, testable state logic” is the driver, BlaC removes the ceremony without removing the testability or the DevTools story.

Redux / RTKBlaC
createSlice + configureStoreclass MyCubit extends Cubit<S> (no setup step)
dispatch(action(payload))cubit.method(payload) — direct call
Reducer handles one action typeMethod is the reducer
useSelector((s) => ...) per hookAuto-tracked read during render — no selector written
useDispatch() + action-creatorSecond element of useBloc tuple
Provider wraps the appNo provider — registry is implicit
createAsyncThunk + extraReducersasync method with inline emit calls
Reselect / createSelectorGetter on the class; no memoization layer needed
Middleware chainPlugin list installed once globally
Single global storeMany independent Cubits; each ref-counted, auto-disposed
  • Comparison — BlaC vs Zustand vs Jotai, with Redux in the honest-comparisons table
  • Core Concepts — state containers, registry, dependency tracking
  • useBloc — full hook reference with args, select, onMount
  • Async — async methods, status unions, cancellation, and why BlaC skips Suspense
  • DevTools — first-party BlaC DevTools plugin