Skip to content

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 todo list with:

  • adding, toggling, and removing items;
  • 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.

Step 1 — the state shape and the first action

Section titled “Step 1 — the state shape and the first action”

Start with the data. A todo has an id, text, and a done flag. The Cubit’s state is a list of them. Nothing else yet.

import {
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.

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
} from '@blac/core';
export interface
interface Todo
Todo
{
Todo.id: string
id
: string;
Todo.text: string
text
: string;
Todo.done: boolean
done
: boolean;
}
export interface
interface TodoState
TodoState
{
TodoState.todos: Todo[]
todos
:
interface Todo
Todo
[];
}
export 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.

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.
this.
StateContainer<TodoState, void, Record<string, never>>.patch(partial: {
todos?: readonly {
id?: string | undefined;
text?: string | undefined;
done?: boolean | 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 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
,
{
id?: string | undefined
id
:
var crypto: Crypto
crypto
.
Crypto.randomUUID(): `${string}-${string}-${string}-${string}-${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.

MDN Reference

randomUUID
(),
text?: string | undefined
text
:
const trimmed: string
trimmed
,
done?: boolean | undefined
done
: false },
],
});
};
}

Two things to notice, both from Quick Start:

  • 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';
function TodoList() {
const [state] = useBloc(TodoCubit);
return (
<ul>
{state.todos.map((t) => (
<li key={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';
function AddRow() {
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.

class
class TodoCubit
TodoCubit
extends
class Base
Base
{
TodoCubit.toggle: (id: string) => void
toggle
= (
id: string
id
: string) => {
this.
StateContainer<TodoState, void, Record<string, never>>.patch(partial: {
todos?: readonly {
id?: string | undefined;
text?: string | undefined;
done?: boolean | 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 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.

@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: Todo
t
) =>
t: Todo
t
.
Todo.id: string
id
===
id: string
id
? { ...
t: Todo
t
,
Todo.done: boolean
done
: !
t: Todo
t
.
Todo.done: boolean
done
} :
t: Todo
t
,
),
});
};
TodoCubit.remove: (id: string) => void
remove
= (
id: string
id
: string) => {
this.
StateContainer<TodoState, void, Record<string, never>>.patch(partial: {
todos?: readonly {
id?: string | undefined;
text?: string | undefined;
done?: boolean | 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 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>.filter(predicate: (value: Todo, index: number, array: Todo[]) => unknown, thisArg?: any): Todo[] (+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: 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:

function TodoRow({ id, text, done }: Todo) {
const [, todo] = useBloc(TodoCubit);
return (
<li>
<input type="checkbox" checked={done} onChange={() => todo.toggle(id)} />
<span>{text}</span>
<button onClick={() => todo.remove(id)}></button>
</li>
);
}

Step 4 — a filter, kept separate from the data

Section titled “Step 4 — a filter, kept separate from the data”

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.

import {
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.

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
} from '@blac/core';
interface
interface Todo
Todo
{
Todo.id: string
id
: string;
Todo.text: string
text
: string;
Todo.done: boolean
done
: boolean;
}
type
type Filter = "all" | "active" | "done"
Filter
= 'all' | 'active' | 'done';
interface
interface TodoState
TodoState
{
TodoState.todos: Todo[]
todos
:
interface Todo
Todo
[];
TodoState.filter: Filter
filter
:
type Filter = "all" | "active" | "done"
Filter
;
}
export 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.

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' });
}
TodoCubit.setFilter: (filter: Filter) => void
setFilter
= (
filter: Filter
filter
:
type Filter = "all" | "active" | "done"
Filter
) => this.
StateContainer<TodoState, void, Record<string, never>>.patch(partial: {
todos?: readonly {
id?: string | undefined;
text?: string | undefined;
done?: boolean | undefined;
}[] | undefined;
filter?: Filter | 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 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.
get
TodoCubit.visible: Todo[]
visible
():
interface Todo
Todo
[] {
const {
const todos: Todo[]
todos
,
const filter: Filter
filter
} = this.
StructuralContainer<TodoState>.state: TodoState
state
;
if (
const filter: Filter
filter
=== 'active') return
const todos: Todo[]
todos
.
Array<Todo>.filter(predicate: (value: Todo, index: number, array: Todo[]) => unknown, thisArg?: any): Todo[] (+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: Todo
t
) => !
t: Todo
t
.
Todo.done: boolean
done
);
if (
const filter: "all" | "done"
filter
=== 'done') return
const todos: Todo[]
todos
.
Array<Todo>.filter(predicate: (value: Todo, index: number, array: Todo[]) => unknown, thisArg?: any): Todo[] (+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: Todo
t
) =>
t: Todo
t
.
Todo.done: boolean
done
);
return
const todos: Todo[]
todos
;
}
get
TodoCubit.remaining: number
remaining
(): number {
return this.
StructuralContainer<TodoState>.state: TodoState
state
.
TodoState.todos: Todo[]
todos
.
Array<Todo>.filter(predicate: (value: Todo, index: number, array: Todo[]) => unknown, thisArg?: any): Todo[] (+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: 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:

function List() {
// Re-render whenever the computed `visible` list changes.
const [, todo] = useBloc(TodoCubit, {
select: (_, bloc) => [bloc.visible],
});
return (
<ul>
{todo.visible.map((t) => (
<TodoRow key={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:

type
type LoadStatus = "idle" | "loading" | "error"
LoadStatus
= 'idle' | 'loading' | 'error';
interface
interface TodoState
TodoState
{
TodoState.todos: Todo[]
todos
:
interface Todo
Todo
[];
TodoState.filter: Filter
filter
:
type Filter = "all" | "active" | "done"
Filter
;
TodoState.status: LoadStatus
status
:
type LoadStatus = "idle" | "loading" | "error"
LoadStatus
;
}
export 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.

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
> {
private
TodoCubit.requestId: number
requestId
= 0;
constructor() {
super({
TodoState.todos: Todo[]
todos
: [],
TodoState.filter: Filter
filter
: 'all',
TodoState.status: LoadStatus
status
: 'idle' });
}
TodoCubit.load: () => Promise<void>
load
= async () => {
const
const reqId: number
reqId
= ++this.
TodoCubit.requestId: number
requestId
; // claim the latest slot
this.
StateContainer<TodoState, void, Record<string, never>>.patch(partial: {
todos?: readonly {
id?: string | undefined;
text?: string | undefined;
done?: boolean | undefined;
}[] | undefined;
filter?: Filter | undefined;
status?: LoadStatus | 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 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
: 'loading' });
try {
const
const todos: Todo[]
todos
= await
const api: {
fetchTodos(): Promise<Todo[]>;
}
api
.
function fetchTodos(): Promise<Todo[]>
fetchTodos
();
if (
const reqId: number
reqId
!== this.
TodoCubit.requestId: number
requestId
) return; // a newer call won; bail
this.
StateContainer<TodoState, void, Record<string, never>>.patch(partial: {
todos?: readonly {
id?: string | undefined;
text?: string | undefined;
done?: boolean | undefined;
}[] | undefined;
filter?: Filter | undefined;
status?: LoadStatus | 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 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
,
status?: LoadStatus | undefined
status
: 'idle' });
} catch {
if (
const reqId: number
reqId
!== this.
TodoCubit.requestId: number
requestId
) return;
this.
StateContainer<TodoState, void, Record<string, never>>.patch(partial: {
todos?: readonly {
id?: string | undefined;
text?: string | undefined;
done?: boolean | undefined;
}[] | undefined;
filter?: Filter | undefined;
status?: LoadStatus | 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 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:

function App() {
const [state, todo] = useBloc(TodoCubit, {
onMount: (bloc) => bloc.load(),
});
if (state.status === 'loading') return <p>Loading…</p>;
if (state.status === 'error') {
return <button onClick={todo.load}>Retry</button>;
}
return <TodoList />;
}

There is no Suspense boundary here, by design — the loading branch of this if is the fallback, written by hand and fully type-checked. The Async guide explains why BlaC models loading as explicit state rather than throwing promises.

Step 6 — route every mutation through one funnel

Section titled “Step 6 — route every mutation through one funnel”

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.

export 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.

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.
private
TodoCubit.commit: (next: Todo[]) => void
commit
= (
next: Todo[]
next
:
interface Todo
Todo
[]) => this.
StateContainer<TodoState, void, Record<string, never>>.patch(partial: {
todos?: readonly {
id?: string | undefined;
text?: string | undefined;
done?: boolean | undefined;
}[] | undefined;
filter?: Filter | 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 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.

trim
();
if (!
const trimmed: string
trimmed
) return;
this.
TodoCubit.commit: (next: Todo[]) => void
commit
([
...this.
StructuralContainer<TodoState>.state: TodoState
state
.
TodoState.todos: Todo[]
todos
,
{
Todo.id: string
id
:
var crypto: Crypto
crypto
.
Crypto.randomUUID(): `${string}-${string}-${string}-${string}-${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.

MDN Reference

randomUUID
(),
Todo.text: string
text
:
const trimmed: string
trimmed
,
Todo.done: boolean
done
: false },
]);
};
TodoCubit.toggle: (id: string) => void
toggle
= (
id: string
id
: string) => {
this.
TodoCubit.commit: (next: Todo[]) => void
commit
(
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.

@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: Todo
t
) => (
t: Todo
t
.
Todo.id: string
id
===
id: string
id
? { ...
t: Todo
t
,
Todo.done: boolean
done
: !
t: Todo
t
.
Todo.done: boolean
done
} :
t: Todo
t
)),
);
};
TodoCubit.remove: (id: string) => void
remove
= (
id: string
id
: string) => {
this.
TodoCubit.commit: (next: Todo[]) => void
commit
(this.
StructuralContainer<TodoState>.state: TodoState
state
.
TodoState.todos: Todo[]
todos
.
Array<Todo>.filter(predicate: (value: Todo, index: number, array: Todo[]) => unknown, thisArg?: any): Todo[] (+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: Todo
t
) =>
t: Todo
t
.
Todo.id: string
id
!==
id: string
id
));
};
TodoCubit.setFilter: (filter: Filter) => void
setFilter
= (
filter: Filter
filter
:
type Filter = "all" | "active" | "done"
Filter
) => this.
StateContainer<TodoState, void, Record<string, never>>.patch(partial: {
todos?: readonly {
id?: string | undefined;
text?: string | undefined;
done?: boolean | undefined;
}[] | undefined;
filter?: Filter | 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 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.
TodoState.filter: Filter
filter
:
type Filter = "all" | "active" | "done"
Filter
;
}
const
const EMPTY: Todo[]
EMPTY
:
interface Todo
Todo
[] = [];
export 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.

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,
// and point the cursor at it.
private
TodoCubit.commit: (next: Todo[]) => void
commit
= (
next: Todo[]
next
:
interface Todo
Todo
[]) => {
const {
const past: Todo[][]
past
,
const cursor: number
cursor
} = this.
StructuralContainer<TodoState>.state: TodoState
state
;
const
const kept: Todo[][]
kept
=
const past: Todo[][]
past
.
Array<Todo[]>.slice(start?: number, end?: number): Todo[][]

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.

@paramstart The beginning index of the specified portion of the array. If start is undefined, then the slice begins at index 0.

@paramend 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.

slice
(0,
const cursor: number
cursor
+ 1);
this.
StateContainer<TodoState, void, Record<string, never>>.patch(partial: {
past?: readonly (readonly {
id?: string | undefined;
text?: string | undefined;
done?: boolean | undefined;
}[])[] | undefined;
cursor?: number | undefined;
filter?: Filter | 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 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
[] {
return this.
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.

trim
();
if (!
const trimmed: string
trimmed
) return;
this.
TodoCubit.commit: (next: Todo[]) => void
commit
([
...this.
TodoCubit.todos: Todo[]
todos
,
{
Todo.id: string
id
:
var crypto: Crypto
crypto
.
Crypto.randomUUID(): `${string}-${string}-${string}-${string}-${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.

MDN Reference

randomUUID
(),
Todo.text: string
text
:
const trimmed: string
trimmed
,
Todo.done: boolean
done
: false },
]);
};
TodoCubit.toggle: (id: string) => void
toggle
= (
id: string
id
: string) => {
this.
TodoCubit.commit: (next: Todo[]) => void
commit
(
this.
TodoCubit.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.

@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: Todo
t
) => (
t: Todo
t
.
Todo.id: string
id
===
id: string
id
? { ...
t: Todo
t
,
Todo.done: boolean
done
: !
t: Todo
t
.
Todo.done: boolean
done
} :
t: Todo
t
)),
);
};
TodoCubit.remove: (id: string) => void
remove
= (
id: string
id
: string) => {
this.
TodoCubit.commit: (next: Todo[]) => void
commit
(this.
TodoCubit.todos: Todo[]
todos
.
Array<Todo>.filter(predicate: (value: Todo, index: number, array: Todo[]) => unknown, thisArg?: any): Todo[] (+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: Todo
t
) =>
t: Todo
t
.
Todo.id: string
id
!==
id: string
id
));
};
TodoCubit.setFilter: (filter: Filter) => void
setFilter
= (
filter: Filter
filter
:
type Filter = "all" | "active" | "done"
Filter
) => this.
StateContainer<TodoState, void, Record<string, never>>.patch(partial: {
past?: readonly (readonly {
id?: string | undefined;
text?: string | undefined;
done?: boolean | undefined;
}[])[] | undefined;
cursor?: number | undefined;
filter?: Filter | 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 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.
TodoCubit.undo: () => void
undo
= () => {
if (this.
TodoCubit.canUndo: boolean
canUndo
) this.
StateContainer<TodoState, void, Record<string, never>>.patch(partial: {
past?: readonly (readonly {
id?: string | undefined;
text?: string | undefined;
done?: boolean | undefined;
}[])[] | undefined;
cursor?: number | undefined;
filter?: Filter | 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 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
: this.
StructuralContainer<TodoState>.state: TodoState
state
.
TodoState.cursor: number
cursor
- 1 });
};
TodoCubit.redo: () => void
redo
= () => {
if (this.
TodoCubit.canRedo: boolean
canRedo
) this.
StateContainer<TodoState, void, Record<string, never>>.patch(partial: {
past?: readonly (readonly {
id?: string | undefined;
text?: string | undefined;
done?: boolean | undefined;
}[])[] | undefined;
cursor?: number | undefined;
filter?: Filter | 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 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
: this.
StructuralContainer<TodoState>.state: TodoState
state
.
TodoState.cursor: number
cursor
+ 1 });
};
TodoCubit.jumpTo: (index: number) => void
jumpTo
= (
index: number
index
: number) => this.
StateContainer<TodoState, void, Record<string, never>>.patch(partial: {
past?: readonly (readonly {
id?: string | undefined;
text?: string | undefined;
done?: boolean | undefined;
}[])[] | undefined;
cursor?: number | undefined;
filter?: Filter | 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 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 {
return this.
StructuralContainer<TodoState>.state: TodoState
state
.
TodoState.cursor: number
cursor
> 0;
}
get
TodoCubit.canRedo: boolean
canRedo
(): boolean {
return this.
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.

length
- 1;
}
get
TodoCubit.visible: Todo[]
visible
():
interface Todo
Todo
[] {
const {
const filter: Filter
filter
} = this.
StructuralContainer<TodoState>.state: TodoState
state
;
if (
const filter: Filter
filter
=== 'active') return this.
TodoCubit.todos: Todo[]
todos
.
Array<Todo>.filter(predicate: (value: Todo, index: number, array: Todo[]) => unknown, thisArg?: any): Todo[] (+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: Todo
t
) => !
t: Todo
t
.
Todo.done: boolean
done
);
if (
const filter: "all" | "done"
filter
=== 'done') return this.
TodoCubit.todos: Todo[]
todos
.
Array<Todo>.filter(predicate: (value: Todo, index: number, array: Todo[]) => unknown, thisArg?: any): Todo[] (+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: Todo
t
) =>
t: Todo
t
.
Todo.done: boolean
done
);
return this.
TodoCubit.todos: Todo[]
todos
;
}
get
TodoCubit.remaining: number
remaining
(): number {
return this.
TodoCubit.todos: Todo[]
todos
.
Array<Todo>.filter(predicate: (value: Todo, index: number, array: Todo[]) => unknown, thisArg?: any): Todo[] (+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: 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:

function HistoryBar() {
const [, todo] = useBloc(TodoCubit, {
select: (_, bloc) => [bloc.canUndo, bloc.canRedo],
});
return (
<div>
<button disabled={!todo.canUndo} onClick={todo.undo}>
Undo
</button>
<button disabled={!todo.canRedo} onClick={todo.redo}>
Redo
</button>
</div>
);
}
function Timeline() {
const [state, todo] = useBloc(TodoCubit, {
select: (_, bloc) => [bloc.state.cursor, bloc.state.past.length],
});
return (
<div>
{state.past.map((_, i) => (
<button
key={i}
className={i === state.cursor ? 'current' : ''}
onClick={() => todo.jumpTo(i)}
/>
))}
</div>
);
}

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.
  • Mental Modelwhy the per-consumer tracking and immutable-state model work the way they do.
  • Dependency Tracking — the full rules behind auto-tracking and select.
  • Async — the loadable surface, cancellation, and the Suspense rationale in depth.
  • Patterns & Recipes — cross-bloc communication, persistence, and more.
  • Persistence — make your todos (or their history) survive a reload.