Design Patterns in TypeScript · Part 8 — Command & Memento
Turn actions into data: the Command pattern for dispatch, queues, and undo/redo, the Memento for snapshotting state, and how reducers (Redux-style) are commands in disguise.
Part 8 of 10 in the Design Patterns in TypeScript series {Phần 8/10 trong series Design Patterns in TypeScript}. Previous {Trước}: Part 7 — Adapter & Facade · Next {Tiếp}: Part 9 — State & State Machines.
This is Part 8 of a 10-part series on the design patterns every senior web engineer should have in their hands — explained with runnable TypeScript, real frontend/back-of-front use cases, and exercises at the end of each part {Đây là Phần 8 của series 10 bài về các design pattern mà mọi senior web nên nắm — giải thích bằng TypeScript chạy được, use case web thực tế, và bài tập ở cuối mỗi phần}. In Part 7 — Adapter & Facade you reshaped what callers see at the boundary {Ở Phần 7 — Adapter & Facade bạn đổi những gì caller thấy ở biên}; here you reshape when and how work runs — as data you can queue, log, and reverse {ở đây bạn đổi khi nào và thế nào việc chạy — thành dữ liệu có thể xếp hàng, ghi log và đảo ngược}.
Calling editor.insert('hello') directly is fine until you need undo, redo, keyboard macro replay, or offline sync {Gọi thẳng editor.insert('hello') ổn cho tới khi bạn cần undo, redo, replay macro, hoặc đồng bộ offline}. The invoker (button, shortcut, middleware) cannot enqueue a method call — only a value {Invoker (nút, phím tắt, middleware) không xếp hàng được lời gọi method — chỉ giá trị}. Reify the action: package intent + parameters (+ how to undo) into an object you pass around {Hiện thực hóa hành động: gói ý định + tham số (+ cách undo) vào một object truyền đi được}.
The intent {Ý đồ}
The Command pattern turns a request into a standalone object with execute() (and often undo()) {Command biến một request thành object độc lập có execute() (và thường undo())}. Three roles {Ba vai}:
- Invoker — triggers commands without knowing internals (toolbar,
dispatch, job runner) {Invoker — kích hoạt command không cần biết bên trong (toolbar,dispatch, job runner)}. - Command — carries everything needed to perform (and reverse) the action {Command — mang đủ thứ để thực hiện (và đảo) hành động}.
- Receiver — the domain object that actually mutates state (document, canvas, cart) {Receiver — object domain thật sự đổi state (document, canvas, giỏ)}.
Memento is the sibling idea: instead of storing how to undo each step, store a snapshot of state you can restore later {Memento là ý tưởng anh em: thay vì lưu cách undo từng bước, lưu snapshot state để khôi phục sau}. Commands answer “what happened?”; mementos answer “what did the world look like at time T?” {Command trả lời “đã xảy ra gì?”; memento trả lời “thế giới trông thế nào lúc T?”}.
A typed command {Command có kiểu}
Two idiomatic shapes in TypeScript {Hai hình idiomatic trong TypeScript}:
- Discriminated union of value objects — serializable, great for reducers, logs, and
JSON.stringify{Discriminated union của value object — serialize được, hợp reducer, log vàJSON.stringify}. - Objects with
execute()/undo()— classic Gang-of-Four, handy when behavior lives on the command itself {Object cóexecute()/undo()— GoF kinh điển, tiện khi hành vi nằm trên command}.
Prefer the union when commands cross the wire (undo stack persisted, collaborative editing, event sourcing) {Ưu tiên union khi command đi qua mạng (stack undo persist, chỉnh sửa cộng tác, event sourcing)}. Prefer methods for a local in-memory editor where you do not need serialization {Ưu tiên method cho editor in-memory local không cần serialize}.
Receiver + union commands {Receiver + command dạng union}
// ── Receiver: the document owns the truth ──
export interface TextDocument {
readonly content: string;
insert(at: number, text: string): void;
delete(at: number, length: number): void;
}
export function createTextDocument(initial = ''): TextDocument {
let content = initial;
return {
get content() {
return content;
},
insert(at, text) {
content = content.slice(0, at) + text + content.slice(at);
},
delete(at, length) {
content = content.slice(0, at) + content.slice(at + length);
},
};
}
// ── Commands as data (serializable intent) ──
export type DocCommand =
| { type: 'insert'; at: number; text: string }
| { type: 'delete'; at: number; length: number };
export function applyCommand(doc: TextDocument, cmd: DocCommand): void {
switch (cmd.type) {
case 'insert':
doc.insert(cmd.at, cmd.text);
break;
case 'delete':
doc.delete(cmd.at, cmd.length);
break;
}
}
// Inverse for undo — derived from the forward command, not guessed.
export function invertCommand(cmd: DocCommand): DocCommand {
switch (cmd.type) {
case 'insert':
return { type: 'delete', at: cmd.at, length: cmd.text.length };
case 'delete':
// Undo delete requires the deleted slice — store it on forward delete in production.
throw new Error('delete undo needs captured text; see exercise 3');
}
}
const doc = createTextDocument('hi');
applyCommand(doc, { type: 'insert', at: 2, text: ' there' });
console.log(doc.content); // hi there
Classic command objects {Command object kinh điển}
export interface Command {
readonly label: string;
execute(): void;
undo(): void;
}
export class InsertTextCommand implements Command {
readonly label = 'Insert text';
constructor(
private readonly doc: TextDocument,
private readonly at: number,
private readonly text: string,
) {}
execute(): void {
this.doc.insert(this.at, this.text);
}
undo(): void {
this.doc.delete(this.at, this.text.length);
}
}
// Invoker only sees Command — not TextDocument internals.
function run(invoker: { push(cmd: Command): void }, cmd: Command): void {
cmd.execute();
invoker.push(cmd);
}
The invoker stays dumb; the command owns the collaboration with the receiver {Invoker vẫn “ngu”; command sở hữu tương tác với receiver}.
Undo / redo with a history stack {Undo / redo với stack lịch sử}
Keep two stacks: past (executed commands) and future (undone commands waiting to redo) {Giữ hai stack: past (command đã chạy) và future (command đã undo chờ redo)}. On execute, push to past and clear future {Khi execute, đẩy vào past và xóa future}. On undo, pop from past, call undo(), push to future {Khi undo, pop past, gọi undo(), đẩy vào future}. On redo, pop from future, execute(), push back to past {Khi redo, pop future, execute(), đẩy lại past}.
export class CommandHistory {
private readonly past: Command[] = [];
private readonly future: Command[] = [];
execute(cmd: Command): void {
cmd.execute();
this.past.push(cmd);
// New branch invalidates redo timeline — standard editor behavior.
this.future.length = 0;
}
undo(): void {
const cmd = this.past.pop();
if (!cmd) return;
cmd.undo();
this.future.push(cmd);
}
redo(): void {
const cmd = this.future.pop();
if (!cmd) return;
cmd.execute();
this.past.push(cmd);
}
canUndo(): boolean {
return this.past.length > 0;
}
canRedo(): boolean {
return this.future.length > 0;
}
}
// Usage
const doc2 = createTextDocument('');
const history = new CommandHistory();
history.execute(new InsertTextCommand(doc2, 0, 'Hello'));
history.execute(new InsertTextCommand(doc2, 5, ' world'));
history.undo();
console.log(doc2.content); // Hello
history.redo();
console.log(doc2.content); // Hello world
For union commands, the same stacks hold DocCommand values; undo applies invertCommand(cmd) instead of calling cmd.undo() {Với union command, stack giữ giá trị DocCommand; undo dùng invertCommand(cmd) thay vì cmd.undo()}.
Memento {Memento}
A memento is an opaque, immutable snapshot of receiver state at a point in time {Memento là snapshot bất biến, không trong suốt của state receiver tại một thời điểm}. The originator (editor) creates and restores mementos; outsiders should not mutate snapshot innards {Originator (editor) tạo và khôi phục memento; bên ngoài không sửa ruột snapshot}.
// Immutable snapshot — copy data out, never hand out live mutable refs.
export interface DocSnapshot {
readonly content: string;
readonly cursor: number;
}
export class EditorWithMemento {
private content = '';
private cursor = 0;
get text(): string {
return this.content;
}
insert(text: string): void {
this.content =
this.content.slice(0, this.cursor) + text + this.content.slice(this.cursor);
this.cursor += text.length;
}
save(): DocSnapshot {
return { content: this.content, cursor: this.cursor };
}
restore(snapshot: DocSnapshot): void {
// Defensive copy so restore cannot alias live editor buffers.
this.content = snapshot.content;
this.cursor = snapshot.cursor;
}
}
const editor = new EditorWithMemento();
editor.insert('draft v1');
const checkpoint = editor.save();
editor.insert(' — edited');
console.log(editor.text); // draft v1 — edited
editor.restore(checkpoint);
console.log(editor.text); // draft v1
When mementos win {Khi memento thắng}: many coupled fields, hard-to-invert operations (layout engines, filters), or “restore to autosave” {nhiều field liên kết, thao tác khó đảo (layout engine, filter), hoặc “khôi phục autosave”}. When commands win {Khi command thắng}: fine-grained undo, audit trail (“user deleted line 42”), smaller memory if each step is tiny {undo chi tiết, audit (“user xóa dòng 42”), bộ nhớ nhỏ nếu mỗi bước nhỏ}. Teams often combine both: command log for collaboration + periodic memento for crash recovery {Đội thường kết hợp: log command cho cộng tác + memento định kỳ cho phục hồi crash}.
Reducers are commands {Reducer chính là command}
Redux and useReducer already use the Command idea {Redux và useReducer đã dùng ý Command}: an action is a plain object describing what happened; the reducer is a pure function that applies it {action là object mô tả đã xảy ra gì; reducer là hàm thuần áp dụng nó}.
interface Todo {
id: string;
text: string;
done: boolean;
}
interface TodoState {
items: Todo[];
}
// Actions = commands (discriminated union)
type TodoAction =
| { type: 'add'; id: string; text: string }
| { type: 'toggle'; id: string }
| { type: 'remove'; id: string };
// Reducer = invoker-side apply, no hidden mutation
export function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'add':
return {
items: [...state.items, { id: action.id, text: action.text, done: false }],
};
case 'toggle':
return {
items: state.items.map((t) =>
t.id === action.id ? { ...t, done: !t.done } : t,
),
};
case 'remove':
return { items: state.items.filter((t) => t.id !== action.id) };
}
}
// dispatch(action) is execute(command)
let state: TodoState = { items: [] };
state = todoReducer(state, { type: 'add', id: '1', text: 'Ship Part 8' });
There is no built-in undo() — you add a history of states or inverse actions yourself {Không có undo() sẵn — bạn tự thêm lịch sử state hoặc action đảo}. Event sourcing pushes the idea further: persist the command stream as the source of truth; rebuild state by folding the log {Event sourcing đẩy xa hơn: persist luồng command làm nguồn sự thật; dựng lại state bằng fold log}. That is Command + immutable snapshots at scale {Đó là Command + snapshot bất biến ở quy mô lớn}.
Real web use cases {Use case web thực tế}
- Undo/redo in editors — Figma, Notion-like docs, code editors: command stacks or operational transforms on command-like ops {Undo/redo trong editor — Figma, doc kiểu Notion, IDE: stack command hoặc OT trên op kiểu command}.
- Optimistic UI with rollback — dispatch a command, update UI, revert with inverse command if the API fails {Optimistic UI + rollback — dispatch command, cập nhật UI, đảo bằng inverse command nếu API fail}.
- Action logging / replay — analytics, support “replay user session”, time-travel debug {Ghi log / replay action — analytics, support “replay session”, debug time-travel}.
- Command queues / offline sync — enqueue
{ type: 'addToCart', sku }while offline; flush when online {Hàng đợi / sync offline — xếp{ type: 'addToCart', sku }khi offline; flush khi online}. - Keyboard shortcut dispatch — one map from
Ctrl+Z→undo()without wiring every button to every handler {Phím tắt — một mapCtrl+Z→undo()không nối từng nút tới từng handler}.
type AppCommand =
| { type: 'navigate'; path: string }
| { type: 'theme'; mode: 'dark' | 'light' }
| { type: 'toast'; message: string };
const shortcutMap: Record<string, AppCommand> = {
'mod+z': { type: 'toast', message: 'Undo not wired in demo' },
'mod+shift+t': { type: 'theme', mode: 'dark' },
};
function dispatchFromKeyboard(
event: { key: string; metaKey: boolean; shiftKey: boolean },
run: (cmd: AppCommand) => void,
): void {
const mod = event.metaKey ? 'mod+' : '';
const key = `${mod}${event.shiftKey ? 'shift+' : ''}${event.key.toLowerCase()}`;
const cmd = shortcutMap[key];
if (cmd) run(cmd);
}
Pitfalls {Cạm bẫy}
- Commands that are not truly reversible — deleting text without storing the deleted slice; undo becomes guesswork {Command không đảo được thật — xóa text mà không lưu đoạn đã xóa; undo thành đoán mò}.
- Huge mementos — snapshotting a 50 MB canvas every keystroke; use deltas, command log, or periodic checkpoints {Memento khổng lồ — snapshot canvas 50 MB mỗi phím; dùng delta, log command, hoặc checkpoint định kỳ}.
- Non-serializable commands — closures holding DOM nodes or class instances break persistence and SSR hydration {Command không serialize — closure giữ DOM hoặc instance class phá persist và hydrate SSR}.
- Mixing side effects into reducers —
fetchinsidetodoReducerbreaks time-travel and tests; keep reducers pure, run effects in middleware/thunks {Trộn side effect vào reducer —fetchtrongtodoReducerphá time-travel và test; giữ reducer thuần, effect ở middleware/thunk}. - Forgetting to clear redo stack — after a new edit post-undo, stale
futurecommands must be discarded {Quên xóa stack redo — sau chỉnh sửa mới sau undo, commandfuturecũ phải bỏ}.
Cheat sheet {Bảng tra nhanh}
// Union command (serializable) + apply
type Action = { type: 'insert'; at: number; text: string };
function apply(state: State, action: Action): State { /* pure */ }
// Classic command object
interface Command { execute(): void; undo(): void; }
// History: past[] + future[], execute clears future
class History {
execute(cmd: Command) { cmd.execute(); past.push(cmd); future = []; }
undo() { const c = past.pop(); c?.undo(); if (c) future.push(c); }
}
// Memento: immutable snapshot
const snap = originator.save();
originator.restore(snap);
// Reducer: (state, action) => state — action IS the command
Decision: need undo + audit + sync → union commands; local OOP editor → command objects; whole-state restore → memento; React/Redux UI → reducer actions {Quyết định: cần undo + audit + sync → union; editor OOP local → command object; khôi phục cả state → memento; UI React/Redux → action reducer}.
Bài tập / Exercises
1. Implement InsertTextCommand and DeleteTextCommand with execute / undo, and a CommandHistory that supports execute, undo, and redo {Cài InsertTextCommand và DeleteTextCommand có execute / undo, và CommandHistory hỗ trợ execute, undo, redo}.
Solution {Lời giải}
class DeleteTextCommand implements Command {
readonly label = 'Delete text';
private removed = '';
constructor(
private readonly doc: TextDocument,
private readonly at: number,
private readonly length: number,
) {}
execute(): void {
this.removed = docSlice(this.doc, this.at, this.length);
this.doc.delete(this.at, this.length);
}
undo(): void {
this.doc.insert(this.at, this.removed);
}
}
function docSlice(doc: TextDocument, at: number, length: number): string {
return doc.content.slice(at, at + length);
}
const doc = createTextDocument('abcdef');
const hist = new CommandHistory();
hist.execute(new DeleteTextCommand(doc, 2, 2));
console.log(doc.content); // abef
hist.undo();
console.log(doc.content); // abcdef
hist.redo();
console.log(doc.content); // abef2. Model the same document edits as a DocAction discriminated union and a pure docReducer(state, action) returning new state (no mutation) {Mô hình hóa cùng thao tác document bằng union DocAction và docReducer(state, action) thuần trả state mới (không mutate)}.
Solution {Lời giải}
interface DocState {
content: string;
}
type DocAction =
| { type: 'insert'; at: number; text: string }
| { type: 'delete'; at: number; length: number };
export function docReducer(state: DocState, action: DocAction): DocState {
const { content } = state;
switch (action.type) {
case 'insert':
return {
content: content.slice(0, action.at) + action.text + content.slice(action.at),
};
case 'delete':
return {
content: content.slice(0, action.at) + content.slice(action.at + action.length),
};
}
}
let s: DocState = { content: 'hi' };
s = docReducer(s, { type: 'insert', at: 2, text: '!' });
console.log(s.content); // hi!3. Extend delete-as-data so undo works: store deletedText on forward delete actions and implement invertAction {Mở rộng delete dạng data để undo chạy: lưu deletedText trên action delete xuôi và cài invertAction}.
Solution {Lời giải}
type DocAction2 =
| { type: 'insert'; at: number; text: string }
| { type: 'delete'; at: number; deletedText: string };
function applyAction2(state: DocState, action: DocAction2): DocState {
return docReducer(state, {
type: action.type,
at: action.at,
...(action.type === 'insert'
? { text: action.text }
: { length: action.deletedText.length }),
});
}
function invertAction2(action: DocAction2): DocAction2 {
switch (action.type) {
case 'insert':
return { type: 'delete', at: action.at, deletedText: action.text };
case 'delete':
return { type: 'insert', at: action.at, text: action.deletedText };
}
}
let s2: DocState = { content: 'abc' };
const del: DocAction2 = { type: 'delete', at: 1, deletedText: 'b' };
s2 = applyAction2(s2, del);
s2 = applyAction2(s2, invertAction2(del));
console.log(s2.content); // abc4. Add save() / restore() memento methods to a small editor and prove restore rolls back multiple edits at once {Thêm save() / restore() memento cho editor nhỏ và chứng minh restore hoàn tác nhiều chỉnh sửa một lần}.
Solution {Lời giải}
class SimpleEditor {
private content = '';
mutate(text: string): void {
this.content += text;
}
read(): string {
return this.content;
}
save(): DocSnapshot {
return { content: this.content, cursor: this.content.length };
}
restore(s: DocSnapshot): void {
this.content = s.content;
}
}
const e = new SimpleEditor();
const snap = e.save();
e.mutate('aaa');
e.mutate('bbb');
e.restore(snap);
console.log(e.read()); // '' — both edits gone5. Why should reducers stay pure while command execute() may mutate a receiver? One sentence each {Vì sao reducer nên thuần còn execute() của command có thể mutate receiver? Mỗi thứ một câu}.
Solution {Lời giải}
Reducer purity enables time-travel debugging, replay, and trivial tests (expect(reducer(s, a)).toEqual(...)) {Reducer thuần cho time-travel, replay và test đơn giản}. Command execute() mutates a receiver when you opt into mutable domain models for performance; the command object still records intent for undo {execute() mutate receiver khi bạn chọn model mutable vì hiệu năng; command object vẫn ghi intent cho undo}.
Stretch {Nâng cao}: persist a DocAction[] log to localStorage, reload, and fold with docReducer to rebuild state — note which fields must be JSON-safe {persist log DocAction[] vào localStorage, reload, fold bằng docReducer để dựng lại state — ghi field nào phải JSON-safe}.
Key takeaways {Điểm chính}
- Command — encapsulate an action as an object/value: queue, log, undo, and dispatch from one place {Command — đóng gói hành động thành object/giá trị: xếp hàng, log, undo, dispatch từ một chỗ}.
- History stack —
past+future; new execute after undo clears redo {Stack lịch sử —past+future; execute mới sau undo xóa redo}. - Memento — immutable snapshots when inverse ops are painful or you need checkpoints {Memento — snapshot bất biến khi đảo op khó hoặc cần checkpoint}.
- Reducers —
(state, action) => statewhereactionis a command; event sourcing is the distributed version {Reducer —(state, action) => statevớiactionlà command; event sourcing là bản phân tán}.
Next up {Tiếp theo}
Part 9 — State & State Machines: stop boolean-flag soup; model allowed transitions explicitly so invalid UI states become unrepresentable {Phần 9 — State & State Machines: bỏ soup cờ boolean; mô hình hóa transition được phép để state UI sai không biểu diễn được}. Continue to Part 9 — State & State Machines. ← Part 7 — Adapter & Facade