01 / The idea
A button starts the work. It doesn’t have to own it.
You’re building a small layout editor. There is one selected card with a name and a horizontal position. At first, the Rename button reads an input and updates the name. A direct handler is clear, easy to follow, and enough for that first interaction.
Now the command palette needs the same action. Extracting a shared function handles that reuse. Later, you want to keep a list of completed edits, show “Undo Rename,” and let another control choose when an action runs. The request itself has become something you want to pass around.
Command represents a request as a value that another part of the program can execute. A rename carries the requested name and knows how to apply it. The button creates the request; the editor decides when to execute it and whether to keep it in history.
A function with captured arguments can be enough. An object becomes useful when you also need a label or several related operations. The decision is to separate preparing a request from invoking its behavior.
02 / See the shape
Carry the new value. Remember the old one when you act.
Start with Basic form: create a rename, then hand it to an invoker with the current document. The same request can come from a button, a palette, or a test. It produces a new document value; the original is left intact.
In the wild grows this into an editor. Rename and Move validate their arguments, return no change when the requested value is already present, and otherwise return a new document together with an inverse. Rename to Launch might return Rename to Release as its inverse. Move to 80 might return Move to 20.
The editor keeps the forward and inverse commands together. Undo runs the inverse and transfers that entry from Past to Future. Redo runs the forward command and transfers it back, keeping the inverse captured when the entry first ran; Undo restored exactly that state, so the inverse is still the right one. A successful new edit clears Future because you have chosen a different continuation.
A rename carries the requested name. An invoker runs it against a document supplied at execution. This small form has no validation or history.
export type BasicCommand = { execute(document: Document): Document };
export function renameBasic(name: string): BasicCommand {
return { execute: (document) => ({ ...document, name }) };
}
export function invokeBasic(document: Document, command: BasicCommand): Document {
return command.execute(document);
} type BasicCommand func(Document) Document
func RenameBasic(name string) BasicCommand {
return func(document Document) Document { document.Name = name; return document }
}
func InvokeBasic(document Document, command BasicCommand) Document {
return command(document)
} The behavior every example promisesResults, no changes, and failures
A document begins as Card at x = 20. Names must contain at least one character; they are preserved exactly, including spaces and Unicode. Positions must be finite whole numbers from 0 through 100.
Commands validate when executed. An invalid request fails before changing state. A valid request for the current value reports no change. Both leave the document, Past, and Future untouched. Empty Undo or Redo also reports no change.
Only successful new edits add an entry and clear Future. Undo and Redo move existing entries. In source snapshots, the last array element is next; the lab displays that entry first. Inspection returns independent document values and label lists, so inspecting history cannot rewrite it.
All edits run synchronously through one editor. Commands must calculate their result without changing the input or performing external work. The shipped Rename and Move commands meet that contract. Arbitrary plugin code is not sandboxed by this interface, and arbitrary JSON needs validation before it becomes a typed request.
Reading the TypeScriptClosures, private fields, and null
rename(name) returns an object whose method closes over the requested name.
It does not read the old name yet. execute(document) reads that later and returns
a separate inverse object. Preparing or reusing a command therefore does not overwrite an
earlier history entry’s inverse.
Change | null separates an edit from an unchanged request; invalid requests
throw. The editor’s #past, #future, and #document fields are private. Readonly is a type constraint,
while Object.freeze protects the current document at runtime. Its two fields are scalar
values, so a shallow copy is enough here.
Reading the GoInterface values, copies, and returned errors
The basic command is a function value. The practical Command interface asks
for Label and Execute; Rename and MoveTo satisfy it through
their methods. We pass structs by value, so the stored commands carry copies of their
string or float64 arguments; the float is deliberate, so a fractional position
can be rejected as it is in TypeScript.
(*Change, error) distinguishes a change, nil, nil for no change,
and a returned error. The document is also passed by value. That is enough for a string and
number; adding slices or maps would require an explicit copying policy. Popped entries are
zeroed before shortening slices so their backing arrays do not retain command references unnecessarily.
Reading the PythonDataclasses, protocols, and exceptions
Document, Change, and the built-in commands are frozen
dataclasses. A Command protocol describes the label and execution method
without forcing inheritance. Rename and MoveTo create a new document
and capture an inverse from the document passed to that execution.
The editor copies document values, moves entries between its two lists, and raises ValueError for invalid requests. The built-in commands are synchronous and
deterministic; a custom collaborator still has to honor the same contract.
03 / Follow the values
There is a Move waiting in the future.
We renamed Card to Release, moved it to 80, then undid the move. Before you touch the controls, predict: will renaming it to Launch keep that Move available? What if you submit Release again, or leave the name empty?
One edit creates a different future.
Start after an undo. Predict what a new edit will do to the pending Move, then execute it.
Past 1
- Renamenext to undo
Future 1
- Movenext to redo
Rename to Release, move to 80, then undo the move. You are back at x = 20, with Move ready to redo.
Try Undo after a successful rename. The old name returns because that execution saved it in an inverse. The old Move branch does not return: starting a new branch discarded it. Undoing the branch’s first edit is still different from recovering a discarded future.
04 / Try a decision
“Before” belongs to an execution.
A command can be prepared before it runs. That makes timing part of the design: the requested value can be captured early, but the value being replaced must come from the state the operation actually receives.
05 / Give it a real job
One open document, one owner of its history.
In a layout editor, create an Editor when a document is opened. A toolbar handler or palette action supplies a fresh command using the user’s chosen value. The editor invokes it, commits its result, and retains its inverse. The view then renders the returned inspection value and updates Undo and Redo availability.
View connections as text
- UI controls: Read user intent and submit a command. Multiple controls can share the same action.
- Editor: Own the current document and history. Invoke first, then commit a successful result.
- Rename / Move: Validate the request; calculate a new document and an inverse using the current value.
- Document: The name and position being edited, returned as a new value.
- UI controls submits request Editor
- Editor retains and invokes Rename / Move
- Rename / Move calculates next value Document
- Editor owns current value Document
The traditional roles are client, invoker, command, and receiver. Here the receiver’s state is a document value. The command returns its result to the editor, which installs it. A classic object version might call methods on a mutable document receiver instead; the responsibility separation can stay the same.
Suppose you add a Rotate action. The document needs an angle, the new command needs its validation and inverse, and the controls need a way to select it. The editor’s history algorithm can stay the same because it calls the command contract without inspecting whether the action is Rename, Move, or Rotate.
Build UIs?A toolbar button, a palette entry, and a shortcut that share one rename already converge on a command, and one day undo will need its inverse.
Where it already is in your components
A Rename button in the toolbar, a “Rename selection” entry in the command palette, and
Ctrl/⌘+Z on the canvas: three controls, one handler, and an undo stack held in component
state, useState in React or $state in Svelte. You have written
this panel. Two lines in it are the lesson. The three entry points converge on a single
value, rename(draft), and none of them knows how a rename is carried out. And
the inverse comes back from execute already holding the name the document had at
that moment, which is section 04’s capture point sitting inside your click handler. The two
arrays are the editor’s Past and Future from section 03; redo runs the stored command again
rather than restoring a saved copy of the document.
Reach for useReducer instead and dispatch({ type: 'rename', name: draft }) makes the same move with the
behavior taken out: the action is a plain record, and the reducer is the invoker that
knows how to apply it. React accepts an action of any type, but
Redux’s style guide rates keeping functions out of actions as essential, so that debugging with the Redux DevTools, which records each action, works as
expected. That is where this lesson’s commands differ. JSON.stringify(rename('Launch')) gives {"label":"Rename"}: the execute method is dropped, so
a saved list of these commands cannot run again, while a saved log of { type, name } records replays through the reducer to the same state.
Dispatch a command object through Redux Toolkit and its development check says so: “A
non-serializable value was detected in an action, in the path: payload.execute.”
When you have to own it
Component state lives as long as the component. Collapse the sidebar, change route, or let
React remount the panel, and the history is gone while the document is still open. A real
app also keeps its shortcuts in one place, a single keydown listener on the document, not
a prop on the canvas. So the editor and its history move into a module that outlives the
panel: React subscribes through useSyncExternalStore, Svelte reads module-level $state. Both
remove the document listener on unmount, and both act only when the canvas is the event’s
target, so the name field keeps native text-input undo. That is the same rule the adapter
below applies at the canvas.
A rename panel: toolbar, palette, and shortcut hand the same rename(draft) value to one handler, and the undo stack is component state.
import { useState, type KeyboardEvent } from 'react';
import { rename, type Command, type Document } from '../editor';
// One entry per successful edit: the command that ran and the inverse it handed
// back. No copies of the document; the inverse is all that undo needs.
type Entry = { forward: Command; inverse: Command };
export function RenameToolbar() {
const [doc, setDoc] = useState<Document>({ name: 'Card', x: 20 });
// The undo stack, in component state. Past and Future are the editor's two
// lists from section 03, kept here because this is where they usually start.
const [past, setPast] = useState<Entry[]>([]);
const [future, setFuture] = useState<Entry[]>([]);
const [draft, setDraft] = useState('');
const [status, setStatus] = useState('Ready');
function run(command: Command) {
try {
const change = command.execute(doc);
if (!change) return setStatus('Unchanged');
// change.inverse was built inside execute() from the name the document
// has right now. That is the capture moment from section 04: the inverse
// of renaming 'Card' exists only because the rename ran against 'Card'.
setDoc(change.document);
setPast([...past, { forward: command, inverse: change.inverse }]);
setFuture([]); // A new edit abandons the redo branch.
setStatus(`${command.label}: ${change.document.name}`);
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Edit failed');
}
}
function undo() {
const entry = past.at(-1);
if (!entry) return;
const change = entry.inverse.execute(doc);
if (!change) return;
setDoc(change.document);
setPast(past.slice(0, -1));
setFuture([...future, entry]);
}
function redo() {
const entry = future.at(-1);
if (!entry) return;
// Redo runs the original command again. Nothing was saved to restore.
const change = entry.forward.execute(doc);
if (!change) return;
setDoc(change.document);
setFuture(future.slice(0, -1));
setPast([...past, entry]);
}
// Three entry points, one value. The toolbar button, the palette entry, and
// the shortcut all arrive here; none of them knows how a rename is done.
const renameSelection = () => run(rename(draft));
function onCanvasKey(event: KeyboardEvent<HTMLDivElement>) {
// Attached to the canvas, so the shortcut fires only while the canvas has
// focus. Typing in the name field keeps the browser's own undo.
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z') return;
event.preventDefault();
if (event.shiftKey) redo();
else undo();
}
return (
<div>
<div role="toolbar" aria-label="Edit">
<button type="button" onClick={renameSelection}>
Rename
</button>
<button type="button" onClick={undo} disabled={past.length === 0}>
Undo
</button>
<button type="button" onClick={redo} disabled={future.length === 0}>
Redo
</button>
</div>
{/* The palette lists actions by name; its entry points at the same handler. */}
<ul role="menu" aria-label="Command palette">
<li role="none">
<button type="button" role="menuitem" onClick={renameSelection}>
Rename selection…
</button>
</li>
</ul>
<input aria-label="Name" value={draft} onChange={(event) => setDraft(event.target.value)} />
<div role="application" tabIndex={0} aria-label="Canvas" onKeyDown={onCanvasKey}>
{doc.name} at x = {doc.x}
</div>
<p role="status">{status}</p>
</div>
);
}
Two controls, one action; shortcuts belong to the focused editorTypeScript browser application
This browser adapter connects two existing buttons to the same rename action. A canvas shortcut sends Undo or Redo to the same editor. It only handles keys targeted at the canvas itself, so typing in the name field keeps native text-input undo.
The caller supplies labeled elements and a document’s editor. Render feedback as text, provide visible Undo/Redo buttons, and call the returned cleanup function when the view is removed. A Svelte component can use equivalent event handlers and its mount/teardown lifecycle; the command implementation does not need to import Svelte.
import { Editor, rename } from './editor';
// Authored browser integration. Supply existing, labeled controls.
// The canvas should have tabindex="0" and an accessible name.
// Both buttons should have type="button".
export function mountEditorControls(
canvas: HTMLElement,
toolbarButton: HTMLButtonElement,
paletteButton: HTMLButtonElement,
nameInput: HTMLInputElement,
status: HTMLElement,
editor: Editor
): () => void {
function render(message: string) {
const { document } = editor.snapshot();
status.textContent = `${message}: ${document.name}, x = ${document.x}`;
}
function renameSelection() {
try {
const changed = editor.execute(rename(nameInput.value));
render(changed ? 'Renamed' : 'Unchanged');
} catch (error) {
status.textContent = error instanceof Error ? error.message : 'Edit failed';
}
}
function onKey(event: KeyboardEvent) {
// Only handle a shortcut on the canvas itself. Text inputs keep native undo.
if (event.target !== canvas || event.isComposing || event.repeat || event.altKey) return;
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z') return;
event.preventDefault();
try {
const changed = event.shiftKey ? editor.redo() : editor.undo();
render(changed ? (event.shiftKey ? 'Redone' : 'Undone') : 'No history');
} catch (error) {
status.textContent = error instanceof Error ? error.message : 'Edit failed';
}
}
toolbarButton.addEventListener('click', renameSelection);
paletteButton.addEventListener('click', renameSelection);
canvas.addEventListener('keydown', onKey);
render('Ready');
return () => {
toolbarButton.removeEventListener('click', renameSelection);
paletteButton.removeEventListener('click', renameSelection);
canvas.removeEventListener('keydown', onKey);
};
}
// During UI mount: const dispose = mountEditorControls(canvas, toolbarButton,
// paletteButton, nameInput, status, new Editor());
// Call dispose() on teardown. Provide visible Undo/Redo controls too.
The assumptions that keep these inverses validLifetime, grouping, and failure
Keep edits in order. An inverse assumes the surrounding history has been undone in reverse order. A remote edit or a direct mutation outside this editor can invalidate that assumption. Collaborative undo needs a conflict or transformation policy.
Choose what one Undo means. Dragging a card through 80 pointer events probably should not require 80 undos. One approach is a temporary drag preview followed by a single committed Move when the gesture ends. Merging commands must retain the first old position and the final new position.
Bound the lifetime. This editor retains history until it is reset or discarded. A real editor needs limits on steps or retained memory. Closing a document should release its editor and UI listeners. Saving a document and marking a history position as saved are separate responsibilities.
Commit only after success. These commands calculate new values without I/O, and the editor changes its document and history only after they return. A command that sends a request and then throws may already have changed the world. Supporting asynchronous operations requires pending-state, ordering, cancellation, and failure rules.
06 / Already in your toolbox
Recognize the contract behind the name.
Notice which part each public API makes explicit: invoking an action, deciding whether it applies, or reversing it.
Qt: an action that can be undone
Qt’s undo framework explicitly uses Command. A QUndoCommand defines redo and
undo behavior; QUndoStack holds the history. The stack owns its commands and discards
an undone branch when a new command is pushed. Our forward/inverse pair expresses a similar
obligation using separate command values.
ProseMirror: functions as editing commands
A ProseMirror command receives editor state and an optional dispatch function. It reports whether it applies; omitting dispatch allows an applicability check without performing the action.
ProseMirror command guide →VS Code: one action, several entry points
registerCommand connects an ID to a handler. Keybindings, UI controls, and executeCommand can invoke it. This combines discoverable names with reusable actions.
Treat it as a counterexample to “a command must have undo”: the general registration API binds
a handler without requiring an inverse.
07 / Make the choice
What do you need to do with the request?
If a button calls a short function immediately, I’d keep that function until another requirement appears. Commands earn their place when the request needs a life beyond that call site: it will be passed to another invoker, retained in history, described in the UI, or scheduled for later.
| Pressure | A simpler starting point | What Command adds |
|---|---|---|
| Two buttons perform the same edit | A shared function removes duplicate behavior. | A request value helps when the caller also needs to pass or retain the action. |
| Undo one local edit | Save a previous state (Memento) when snapshots are small and sufficient. | Retain the operation and the information needed to reverse that execution. |
| Add a third kind of edit | A small, closed reducer can handle a tagged action clearly. | A new implementation can supply behavior through the existing invoker contract. |
| Execute a request after restart | Persist a validated data record with a handler. | The request model can guide that design, but closures and trait objects need a separate serializable representation. |
Queues, logs, and macros ask for moreUseful extensions with distinct contracts
A background job can represent intent as data: an operation name, target ID, arguments, and schema version. A worker resolves and executes it later. Persistence does not make retries safe; the operation still needs a duplicate-delivery policy.
A macro can group commands into one user action, but failure halfway through raises a new question: are earlier steps rolled back, retained as partial success, or performed in a transaction? Running a list in a loop does not supply atomicity.
A command requests an action. An event records something that happened. Logging attempted commands alone is not a trustworthy history of successful state changes.
08 / Take the idea with you
Explain what the button hands off.
“The controls create an edit request. The editor executes it and keeps the inverse from that execution, so another control can reverse the last completed edit.” You can explain the design before naming the pattern.
Try a transfer: add Rotate. Write its input rule, unchanged case, and inverse. Then explain why renaming to the current name should preserve a pending redo. Finally, find an action in your own application and ask whether passing it as a value would solve a concrete problem.
Connections to follow nextRelated lessons
- Stack supplies the order for undo and redo. Command supplies executable requests and, in this example, their inverses.
- Memento preserves restorable state. A command can use a snapshot when an inverse operation would be awkward. Our Rename inverse remembers one previous field value; it does not save the entire document.
- Strategy supplies a policy to an ongoing workflow. Command packages a request to be invoked. Both can use functions; the responsibility explains the distinction.
- Registry can find a command handler by name. Discovery is separate from execution and history.
- Publish / subscribe can announce that an edit completed. Publishing that fact is separate from requesting the edit.