Tutorial: build a Todo app, then make it time-travel
This is the long way round — one app, built in seven numbered steps, each a small diff on the last. We start with an empty Cubit and finish with full undo / redo and time travel, all from the same state primitives you meet in the first step. No new APIs appear at the end; the payoff is that BlaC’s plain-immutable-state model makes undo fall out almost for free.
If you have not read Quick Start and Core Concepts yet, skim them first — this page assumes you know what emit/update/patch do and that useBloc returns a [state, bloc] tuple. Everything else is built up here.
There are two interactive checkpoints: one when the app first becomes usable, one at the end with time travel. The code blocks in between are type-checked against the real published API by the docs build, so they compile exactly as written.
a filter (all / active / done) that is view state, kept separate from the data;
a derived “items left” count;
and — the finale — a full history stack so every change can be undone, redone, or jumped to.
The whole thing is one Cubit. Every component reads only the slice it needs, so adding a todo never re-renders the filter chips, and toggling the filter never re-renders the add box. That isolation is automatic; you will see it without writing a single selector for most of the app.
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<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<
interface TodoState
TodoState> {
constructor() {
super({
TodoState.todos: Todo[]
todos: [] });
}
TodoCubit.add: (text:string)=>void
add=(
text: string
text:string)=> {
const
const trimmed:string
trimmed =
text: string
text.
String.trim(): string
Removes the leading and trailing white space and line terminator characters from a string.
trim();
if (!
const trimmed:string
trimmed) return;
// patch deep-merges: we mention only the key we change.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so 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.
The randomUUID() method of the Crypto interface is used to generate a v4 UUID using a cryptographically secure random number generator.
Available only in secure contexts.
add is an arrow-function field, not a method, so this stays bound when you pass todo.add straight to an event handler.
We build the next array with a spread ([...this.state.todos, …]) rather than push. State is treated as immutable; you always hand patch/emit a fresh value. That immutability is exactly what makes Step 7 possible.
useBloc(TodoCubit) gives this component the shared instance and subscribes it to the state it reads. Here it reads state.todos, so it wakes whenever the list changes.
import { useBloc } from'@blac/react';
import { TodoCubit } from'./TodoCubit';
functionTodoList() {
const [state] = useBloc(TodoCubit);
return (
<ul>
{state.todos.map((t)=> (
<likey={t.id}>{t.text}</li>
))}
</ul>
);
}
And an input to drive add. This component reads nothing from state — it only calls an action — so it never re-renders when the list changes:
import { useState } from'react';
import { useBloc } from'@blac/react';
import { TodoCubit } from'./TodoCubit';
functionAddRow() {
const [text,setText] = useState('');
const [,todo] = useBloc(TodoCubit); // only the bloc, no state read
return (
<input
value={text}
onChange={(e)=>setText(e.target.value)}
onKeyDown={(e)=> {
if(e.key==='Enter') {
todo.add(text);
setText('');
}
}}
/>
);
}
That const [, todo] = — dropping the state slot — is the idiom for an action-only consumer. There is no subscription to anything, so nothing wakes it.
Two more actions on the Cubit. Both produce a brand-new array; neither mutates the old one. toggle maps over the list flipping one item’s done; remove filters it out.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so 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({
todos?: readonly {
id?: string |undefined;
text?: string |undefined;
done?: boolean |undefined;
}[] |undefined
todos: this.
StructuralContainer<TodoState>.state: TodoState
state.
TodoState.todos: Todo[]
todos.
Array<Todo>.map<Todo>(callbackfn: (value:Todo, index:number, array:Todo[])=> Todo, thisArg?: any): Todo[]
Calls a defined callback function on each element of an array, and returns an array that contains the results.
@param ― callbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.
@param ― thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so 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.
Returns the elements of an array that meet the condition specified in a callback function.
@param ― predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.
@param ― thisArg 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: Todo
t)=>
t: Todo
t.
Todo.id: string
id!==
id: string
id) });
};
}
In the row component, wire a checkbox to toggle and a button to remove. A row only needs the bloc, plus the props passed by its parent:
The filter is view state: which subset of the list to show. It is not part of the todo data, so it gets its own key. Crucially, keeping it separate is what lets the history in Step 7 record only the data and ignore the view.
Add a filter field and a derived visible getter that applies it. Getters recompute on every read, so visible can never drift out of sync with todos or filter.
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<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.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so 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({
filter?: Filter |undefined
filter });
// Derived on every read — never stored, never stale.
Returns the elements of an array that meet the condition specified in a callback function.
@param ― predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.
@param ― thisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
Returns the elements of an array that meet the condition specified in a callback function.
@param ― predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.
@param ― thisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
Returns the elements of an array that meet the condition specified in a callback function.
@param ― predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.
@param ― thisArg 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: Todo
t)=>!
t: Todo
t.
Todo.done: boolean
done).
Array<Todo>.length: number
Gets or sets the length of the array. This is a number one higher than the highest index in the array.
length;
}
}
Auto-tracking handles getters read during render: the todo value returned by useBloc is proxied, so todo.visible records the todos and filter reads inside the getter. Here we still use select because visible allocates a filtered array; the selector makes that computed array the explicit re-render boundary:
functionList() {
// Re-render whenever the computed `visible` list changes.
const [,todo] = useBloc(TodoCubit, {
select: (_, bloc) => [bloc.visible],
});
return (
<ul>
{todo.visible.map((t)=> (
<TodoRowkey={t.id}{...t} />
))}
</ul>
);
}
select returns an array; the component re-renders when any entry changes by identity. That identity boundary matters: this getter may allocate a new filtered array when it runs, so keep the selector focused on values that should wake the list. In this tutorial, todos and filter are the only fields that affect visible, so this wakes the list at the right moments. If unrelated state changes also cause the selector to run, memoize the derived array or select the source fields instead.
That is a complete, usable todo app: add, toggle, remove, filter, and a live “items left” count. Try it below — add a few items, toggle some, switch filters. Hit View code and edit anything; the preview re-runs live.
Open /TodoCubit.ts in the sandbox — that one class is the entire app’s logic. The components in /App.tsx are thin: each calls useBloc, reads its slice, and renders. No reducers, no actions object, no provider.
Real apps load their initial todos. An async action is just a method that awaits and emits as it goes — BlaC has no special async primitive. We model the load lifecycle as state so the view can render “loading” and “error” instead of guessing.
We will track the load status alongside the todos. A request-id guard makes a slow response unable to clobber a newer one — the full reasoning is in the Async guide; here is the shape applied to our Cubit:
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.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so 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.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so 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.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so 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({
status?: LoadStatus |undefined
status: 'error' });
}
};
}
Kick the load off when the view appears using the onMount option — it fires after the bloc is acquired:
functionApp() {
const [state,todo] = useBloc(TodoCubit, {
onMount: (bloc) => bloc.load(),
});
if (state.status==='loading') return<p>Loading…</p>;
if (state.status==='error') {
return<buttononClick={todo.load}>Retry</button>;
}
return<TodoList />;
}
There is no Suspense boundary here, by design — the loading branch of this ifis the fallback, written by hand and fully type-checked. The Async guide explains why BlaC models loading as explicit state rather than throwing promises.
Before we can add history, every change to the todo list must flow through a single place. Right now add, toggle, and remove each call patch({ todos: … }) directly. Refactor them to compute the next list and hand it to one private commit helper.
This is a pure refactor — behavior is identical — but it gives us the one chokepoint history will hook into.
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<
interface TodoState
TodoState> {
constructor() {
super({
TodoState.todos: Todo[]
todos: [],
TodoState.filter: Filter
filter: 'all' });
}
// The single funnel: takes the next todo list and commits it.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so 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({
todos?: readonly {
id?: string |undefined;
text?: string |undefined;
done?: boolean |undefined;
}[] |undefined
todos:
next: Todo[]
next });
TodoCubit.add: (text:string)=>void
add=(
text: string
text:string)=> {
const
const trimmed:string
trimmed =
text: string
text.
String.trim(): string
Removes the leading and trailing white space and line terminator characters from a string.
The randomUUID() method of the Crypto interface is used to generate a v4 UUID using a cryptographically secure random number generator.
Available only in secure contexts.
Array<Todo>.map<Todo>(callbackfn: (value:Todo, index:number, array:Todo[])=> Todo, thisArg?: any): Todo[]
Calls a defined callback function on each element of an array, and returns an array that contains the results.
@param ― callbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.
@param ― thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
Returns the elements of an array that meet the condition specified in a callback function.
@param ― predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.
@param ― thisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so 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({
filter?: Filter |undefined
filter });
}
Note setFilter does not go through commit — switching filters should not create an undo entry. That separation we set up in Step 4 is paying off already.
Now the finale. Instead of storing a single todos array, store an array of every list we have ever committed — a past stack — plus a cursor pointing at the one currently shown. commit appends; undo/redo just move the cursor; jumpTo sets it anywhere.
Because BlaC state is plain immutable values, a list of past values is already a complete undo history. There is no diffing, no command pattern, no special engine — the snapshots are the values you were emitting all along.
interface
interface TodoState
TodoState {
// Every committed snapshot of the todo list, oldest first.
TodoState.past: Todo[][]
past:
interface Todo
Todo[][];
// Index into `past` of the snapshot we are showing.
TodoState.cursor: number
cursor:number;
// Filter stays OUTSIDE history — switching it is not undoable.
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<
interface TodoState
TodoState> {
constructor() {
super({
TodoState.past: Todo[][]
past: [
const EMPTY:Todo[]
EMPTY],
TodoState.cursor: number
cursor: 0,
TodoState.filter: Filter
filter: 'all' });
}
// Drop any "future" we had undone past, append the new snapshot,
Returns a copy of a section of an array.
For both start and end, a negative index can be used to indicate an offset from the end of the array.
For example, -2 refers to the second to last element of the array.
@param ― start The beginning index of the specified portion of the array.
If start is undefined, then the slice begins at index 0.
@param ― end The end index of the specified portion of the array. This is exclusive of the element at the index 'end'.
If end is undefined, then the slice extends to the end of the array.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so 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({
past?:readonly (readonly {
id?: string |undefined;
text?: string |undefined;
done?: boolean |undefined;
}[])[] |undefined
past: [...
const kept:Todo[][]
kept,
next: Todo[]
next],
cursor?: number |undefined
cursor:
const kept:Todo[][]
kept.
Array<Todo[]>.length: number
Gets or sets the length of the array. This is a number one higher than the highest index in the array.
length });
};
// The list everything else reads: whatever the cursor points at.
get
TodoCubit.todos: Todo[]
todos():
interface Todo
Todo[] {
returnthis.
StructuralContainer<TodoState>.state: TodoState
state.
TodoState.past: Todo[][]
past[this.
StructuralContainer<TodoState>.state: TodoState
state.
TodoState.cursor: number
cursor];
}
TodoCubit.add: (text:string)=>void
add=(
text: string
text:string)=> {
const
const trimmed:string
trimmed =
text: string
text.
String.trim(): string
Removes the leading and trailing white space and line terminator characters from a string.
The randomUUID() method of the Crypto interface is used to generate a v4 UUID using a cryptographically secure random number generator.
Available only in secure contexts.
Array<Todo>.map<Todo>(callbackfn: (value:Todo, index:number, array:Todo[])=> Todo, thisArg?: any): Todo[]
Calls a defined callback function on each element of an array, and returns an array that contains the results.
@param ― callbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.
@param ― thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
Returns the elements of an array that meet the condition specified in a callback function.
@param ― predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.
@param ― thisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so 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({
filter?: Filter |undefined
filter });
// Time travel: just move the cursor. No todo data is mutated.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so 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.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so 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.
Override of StructuralContainer.patch that routes through the
StateContainer concerns: disposed guard, dev-only emit-rate check,
_changedWhileHydrating flag, pending-change capture (so 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({
cursor?: number |undefined
cursor:
index: number
index });
get
TodoCubit.canUndo: boolean
canUndo():boolean {
returnthis.
StructuralContainer<TodoState>.state: TodoState
state.
TodoState.cursor: number
cursor>0;
}
get
TodoCubit.canRedo: boolean
canRedo():boolean {
returnthis.
StructuralContainer<TodoState>.state: TodoState
state.
TodoState.cursor: number
cursor<this.
StructuralContainer<TodoState>.state: TodoState
state.
TodoState.past: Todo[][]
past.
Array<Todo[]>.length: number
Gets or sets the length of the array. This is a number one higher than the highest index in the array.
Returns the elements of an array that meet the condition specified in a callback function.
@param ― predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.
@param ― thisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
Returns the elements of an array that meet the condition specified in a callback function.
@param ― predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.
@param ― thisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
Returns the elements of an array that meet the condition specified in a callback function.
@param ― predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.
@param ― thisArg 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: Todo
t)=>!
t: Todo
t.
Todo.done: boolean
done).
Array<T>.length: number
Gets or sets the length of the array. This is a number one higher than the highest index in the array.
length;
}
}
What changed, and what did not:
todos became a getter over past[cursor]. Every action and getter that used to read this.state.todos now reads this.todos instead — a one-word change at each call site.
add / toggle / remove are untouched apart from that. They still compute a next list and call commit. The funnel from Step 6 is the only thing that needed to learn about history.
undo / redo / jumpTo mutate nothing but the cursor. Going back in time is not a destructive operation; the future snapshots are still there until you commit a new change over them.
filter is still outside the stack, so switching filters or rewinding history are completely independent.
The undo/redo buttons drive undo/redo; a row of dots renders one button per snapshot and calls jumpTo:
Add several todos, toggle a few, then hit Undo repeatedly and watch the list rewind one change at a time. Redo walks forward again. Click any dot in the history row to jump straight to that point. Switch filters at any cursor position — the filter never disturbs the timeline.
Open /TodoCubit.ts and compare it with the Step 4 version above. The diff that bought you a full undo system is small: a past/cursor pair instead of a bare todos array, a slightly smarter commit, three cursor-moving methods, and two boolean getters. Everything else — the actions, the components, the filter — barely moved.
One Cubit, many thin consumers. Each component reads only its slice via useBloc; action-only components read nothing and never re-render.
patch for the data path; the replace-vs-merge rule from Quick Start. State is always handed over as a fresh immutable value, never mutated in place.
Derived getters over stored duplicates.visible, remaining, canUndo recompute on read and cannot drift; auto-tracking records getter reads in render, and select can gate on getter results.
View state lives apart from durable data.filter and load status describe rendering, so they stay out of the history that records the todos.
A single mutation funnel makes cross-cutting features cheap. Once every change flowed through commit, undo/redo/time-travel was an afternoon, not a rewrite.
Async is just methods that emit. A request-id guard and an explicit status union replace Suspense — see the Async guide.