Skip to content

Undo / Redo

Use when: users need to reverse discrete actions — a text editor, a canvas, a form with destructive bulk edits. Don’t use when: the state is large and serialization is expensive; consider structural sharing or operation-log approaches instead.

Keep a stack of past states and a stack of future states alongside the current one. Each mutation saves the previous state to past; undo pops from past and pushes to future; redo reverses that.

class
class EditorCubit
EditorCubit
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
<
interface EditorState
EditorState
,
interface Note
Note
> {
// Hard cap prevents unbounded memory growth.
private static readonly
EditorCubit.MAX_HISTORY: 50
MAX_HISTORY
= 50;
// One instance per note title — args both seed and key the instance.
static
EditorCubit.key: (initial: Note) => string
key
= (
initial: Note
initial
:
interface Note
Note
) =>
initial: Note
initial
.
Note.title: string
title
;
constructor() {
super({
EditorState.note: Note
note
: {
Note.title: string
title
: '',
Note.body: string
body
: '' },
EditorState.past: Note[]
past
: [],
EditorState.future: Note[]
future
: [] });
}
protected
EditorCubit.init(initial: Note): void

Called once after construction with the args passed at acquire time, before the first state snapshot is read by any consumer. Override to seed args-derived state (via this.emit(...)) or kick off loads.

init
(
initial: Note
initial
:
interface Note
Note
) {
this.
StateContainer<EditorState, Note, Record<string, never>>.emit(next: EditorState): void
emit
({
EditorState.note: Note
note
:
initial: Note
initial
,
EditorState.past: Note[]
past
: [],
EditorState.future: Note[]
future
: [] });
}
/** Push the current note onto the past stack, then apply `next`. */
private
EditorCubit.applyChange(next: Note): void

Push the current note onto the past stack, then apply next.

applyChange
(
next: Note
next
:
interface Note
Note
) {
const {
const note: Note
note
,
const past: Note[]
past
} = this.
StructuralContainer<EditorState>.state: EditorState
state
;
const
const trimmed: Note[]
trimmed
=
const past: Note[]
past
.
Array<Note>.slice(start?: number, end?: number): Note[]

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
(-(
class EditorCubit
EditorCubit
.
EditorCubit.MAX_HISTORY: 50
MAX_HISTORY
- 1));
this.
StateContainer<EditorState, Note, Record<string, never>>.emit(next: EditorState): void
emit
({
EditorState.note: Note
note
:
next: Note
next
,
EditorState.past: Note[]
past
: [...
const trimmed: Note[]
trimmed
,
const note: Note
note
],
EditorState.future: Note[]
future
: [] });
// ⚠️ Always clear `future` on a new edit — an undo-then-type flow should
// not let the user redo the overwritten branch.
}
EditorCubit.setTitle: (title: string) => void
setTitle
= (
title: string
title
: string) => {
this.
EditorCubit.applyChange(next: Note): void

Push the current note onto the past stack, then apply next.

applyChange
({ ...this.
StructuralContainer<EditorState>.state: EditorState
state
.
EditorState.note: Note
note
,
Note.title: string
title
});
};
EditorCubit.setBody: (body: string) => void
setBody
= (
body: string
body
: string) => {
this.
EditorCubit.applyChange(next: Note): void

Push the current note onto the past stack, then apply next.

applyChange
({ ...this.
StructuralContainer<EditorState>.state: EditorState
state
.
EditorState.note: Note
note
,
Note.body: string
body
});
};
EditorCubit.undo: () => void
undo
= () => {
const {
const note: Note
note
,
const past: Note[]
past
,
const future: Note[]
future
} = this.
StructuralContainer<EditorState>.state: EditorState
state
;
if (
const past: Note[]
past
.
Array<Note>.length: number

Gets or sets the length of the array. This is a number one higher than the highest index in the array.

length
=== 0) return;
const
const previous: Note
previous
=
const past: Note[]
past
[
const past: Note[]
past
.
Array<Note>.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];
this.
StateContainer<EditorState, Note, Record<string, never>>.emit(next: EditorState): void
emit
({
EditorState.note: Note
note
:
const previous: Note
previous
,
EditorState.past: Note[]
past
:
const past: Note[]
past
.
Array<Note>.slice(start?: number, end?: number): Note[]

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, -1),
EditorState.future: Note[]
future
: [
const note: Note
note
, ...
const future: Note[]
future
],
});
};
EditorCubit.redo: () => void
redo
= () => {
const {
const note: Note
note
,
const past: Note[]
past
,
const future: Note[]
future
} = this.
StructuralContainer<EditorState>.state: EditorState
state
;
if (
const future: Note[]
future
.
Array<Note>.length: number

Gets or sets the length of the array. This is a number one higher than the highest index in the array.

length
=== 0) return;
const
const next: Note
next
=
const future: Note[]
future
[0];
this.
StateContainer<EditorState, Note, Record<string, never>>.emit(next: EditorState): void
emit
({
EditorState.note: Note
note
:
const next: Note
next
,
EditorState.past: Note[]
past
: [...
const past: Note[]
past
,
const note: Note
note
],
EditorState.future: Note[]
future
:
const future: Note[]
future
.
Array<Note>.slice(start?: number, end?: number): Note[]

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
(1),
});
};
get
EditorCubit.canUndo: boolean
canUndo
() {
return this.
StructuralContainer<EditorState>.state: EditorState
state
.
EditorState.past: Note[]
past
.
Array<Note>.length: number

Gets or sets the length of the array. This is a number one higher than the highest index in the array.

length
> 0;
}
get
EditorCubit.canRedo: boolean
canRedo
() {
return this.
StructuralContainer<EditorState>.state: EditorState
state
.
EditorState.future: Note[]
future
.
Array<Note>.length: number

Gets or sets the length of the array. This is a number one higher than the highest index in the array.

length
> 0;
}
}
function NoteEditor({ initial }: { initial: { title: string; body: string } }) {
// `static key` derives identity from the title — one EditorCubit per note.
const [state, editor] = useBloc(EditorCubit, {
args: initial,
select: (s, bloc) => [
s.note.title,
s.note.body,
bloc.canUndo,
bloc.canRedo,
],
});
return (
<div>
<input
value={state.note.title}
onChange={(e) => editor.setTitle(e.target.value)}
/>
<textarea
value={state.note.body}
onChange={(e) => editor.setBody(e.target.value)}
/>
<button onClick={editor.undo} disabled={!editor.canUndo}>
Undo
</button>
<button onClick={editor.redo} disabled={!editor.canRedo}>
Redo
</button>
</div>
);
}
  • Cubitemit replaces wholesale; update derives from current state
  • Patterns — getter-based computed values (canUndo/canRedo)