The command pattern is a very popular design pattern that was introduced in the GoF design patterns book. The main use case for this pattern is to decouple the code for handling some request from the code invoking it.
A common example of this is extracting the code in your page controller to a command object and execute it there. Doing this allows you to replace that command object with another one at runtime to modify the behavior of that request dynamically.
export function handleGetRequest({ request }) {
const command = new ExampleCommand(request.data)
command.execute()
}
Another use case for the command pattern is to replace your long, complex function with a command object. In the command class, you can simplify the code a lot by splitting your code into smaller methods with clear intent and focus.
This might be more helpful in languages that don't have functions as first-class citizens, like Java. In JavaScript you don't have to use commands for that purpose (although you can); you can instead use nested functions.
Another good use case for commands—which is what I'm going to talk about in this article—is supporting undo functionality.
The source code for the example
I think it will be easier to follow along when you see the whole code of this article's example. So I encourage you to get it from GitHub and take a look at it before continuing.
Defining the example
Before showing you how to use commands for supporting undos, I need to show you the example I will use it on.
The example is a simple editor that uses a <textarea>
element for editing the text. The editor has a toolbar with three buttons: undo, bold, and italic.
To make some text bold or italic, you need to select the text in the editor, and then click bold or italic. Clicking bold will wrap the selected text with <strong></strong>
; whereas clicking italic will wrap it with <i></i>
.
In this example, I'm encapsulating the textarea
element with a class called Editor. In this class I have all the needed code for wrapping the selected text with some wrapping text.
You don't have to understand how it works; you just need to know that calling boldSelection
or italicizeSelection
makes the selected text bold or italic.
// Editor.js
export class Editor {
#textareaElement
constructor(textareaElement) {
this.#textareaElement = textareaElement
}
get content() {
return this.#textareaElement.value
}
set content(value) {
this.#textareaElement.value = value
}
get #selectedText() {
return this.content.slice(
this.selectionRange.start,
this.selectionRange.end
)
}
get selectionRange() {
return {
start: this.#textareaElement.selectionStart,
end: this.#textareaElement.selectionEnd
}
}
select(selectionRange) {
this.#textareaElement.focus()
this.#textareaElement.setSelectionRange(
selectionRange.start,
selectionRange.end
)
}
get #hasSelection() {
return this.selectionRange.start !== this.selectionRange.end
}
#wrapSelectionWith(wrapperStart, wrapperEnd) {
if (!this.#hasSelection) return
const previousSelection = {
start: this.selectionRange.start + wrapperStart.length,
end: this.selectionRange.end + wrapperStart.length
}
const textBeforeSelection = this.content.slice(0, this.selectionRange.start)
const textAfterSelection = this.content.slice(this.selectionRange.end)
const wrappedText = wrapperStart + this.#selectedText + wrapperEnd
this.content = textBeforeSelection + wrappedText + textAfterSelection
requestAnimationFrame(() => {
this.select(previousSelection)
})
}
boldSelection() {
this.#wrapSelectionWith('<strong>', '</strong>')
}
italicizeSelection() {
this.#wrapSelectionWith('<i>', '</i>')
}
}
I have this html for this example:
<body>
<div id="app">
<div class="editor">
<div class="toolbar">
<button id="undo">undo</button>
<button id="bold">bold</button>
<button id="italic">italic</button>
</div>
<textarea id="textarea"></textarea>
</div>
</div>
<script type="module" src="./index.js"></script>
<script type="module">
import { init } from './index.js'
init()
</script>
</body>
Notice how I call init()
to initialize the app state. This function is defined in index.js
like this:
// index.js
import { Editor } from './Editor.js'
export function init() {
const undo = document.getElementById('undo')
const bold = document.getElementById('bold')
const italic = document.getElementById('italic')
const textarea = document.getElementById('textarea')
const editor = new Editor(textarea)
bold.addEventListener('mousedown', () => {
editor.boldSelection()
})
italic.addEventListener('mousedown', () => {
editor.italicizeSelection()
})
}
Support undo with the command pattern
First, I need to extract each editor action into a command. The command is a simple class with an execute
method. Let's start with bold.
// EditorCommand.js
class BoldCommand {
#editor
constructor(editor) {
this.#editor = editor
}
execute() {
this.#editor.boldSelection()
}
}
Then I need to update index.js
to use it instead of using the editor object directly.
// index.js
import { Editor } from './Editor.js'
import { BoldCommand } from './EditorCommand.js'
export function init() {
//...
const editor = new Editor(textarea)
bold.addEventListener('mousedown', () => {
const command = new BoldCommand(editor)
command.execute()
})
}
The BoldCommand
has a direct reference to the editor. This means I can use the editor in the context of that command however I want. So if the execute
method is just for making the text bold, this means I can add any other method to modify the editor in that context. Adding undo
is a perfect example of that.
// EditorCommand.js
class BoldCommand {
#editor
#previousContent
constructor(editor) {
this.#editor = editor
this.#previousContent = this.#editor.content
}
execute() {
this.#editor.boldSelection()
}
undo() {
this.#editor.content = this.#previousContent
}
}
When BoldCommand
is created, I save the content of that editor in the #previousContent
field. Calling execute
would modify the editor content using this.#editor.boldSelection()
, while previousContent
still contains the previous content before executing the command. Calling undo
after that will set the content of the editor to the previous content; thus removing the bold text.
In index.js
you can add an event listener to the undo button to call undo()
on the bold command.
// index.js
import { Editor } from './Editor.js'
import { BoldCommand } from './EditorCommand.js'
export function init() {
//...
const editor = new Editor(textarea)
const boldCommand = new BoldCommand(editor)
bold.addEventListener('mousedown', () => {
boldCommand.execute()
})
undo.addEventListener('click', () => {
boldCommand.undo()
})
}
That would work for that specific command. But in real-world projects, the undo button should work on multiple commands with different types. That's what the command manager is for.
The command manager is an object that has an array in which it stores the executed commands. Storing the executed commands in it allows you to keep track of the commands that you want to call undo
on. That array should work as a stack—LIFO (last in, first out). To do this, you need to call push
to add a new command and call pop
to take out a command to call undo
on.
// CommandManager.js
export class CommandManager {
#commands = []
execute(command) {
command.execute()
this.#commands.push(command)
}
undo() {
if (this.#commands.length <= 0) return
const command = this.#commands.pop()
command.undo()
}
}
After introducing the CommandManager
to your code, you need to execute the commands through it—instead of individually—to store the command in the commands stack.
As shown in the code above, calling undo
on the CommandManager
will take the last command and call undo
on it.
Now let's update index.js
to use the command manager.
// index.js
import { Editor } from './Editor.js'
import { CommandManager } from './CommandManager.js'
import { BoldCommand } from './EditorCommand.js'
export function init() {
//...
const editor = new Editor(textarea)
const commandManager = new CommandManager()
bold.addEventListener('mousedown', () => {
commandManager.execute(new BoldCommand(editor))
})
undo.addEventListener('click', () => {
commandManager.undo()
})
}
The undo button should work as expected for the bold command. Next, let's do the same for italic button.
Add ItalicizeCommand
The ItalicizeCommand
will be the same as BoldCommand
except the execute
method. This means I can DRY up the code by creating a super class to inherit from. Let's call it EditorCommand
.
// EditorCommand.js
class EditorCommand {
_editor
#previousContent
constructor(editor) {
this._editor = editor
this.#previousContent = this._editor.content
}
execute() {
throw new Error('execute is an abstract method')
}
undo() {
this._editor.content = this.#previousContent
}
}
export class BoldCommand extends EditorCommand {
execute() {
this._editor.boldSelection()
}
}
export class ItalicizeCommand extends EditorCommand {
execute() {
this._editor.italicizeSelection()
}
}
Finally, you need to update index.js
to use it.
// index.js
import { Editor } from './Editor.js'
import { CommandManager } from './CommandManager.js'
import { BoldCommand, ItalicizeCommand } from './EditorCommand.js'
export function init() {
const undo = document.getElementById('undo')
const bold = document.getElementById('bold')
const italic = document.getElementById('italic')
const textarea = document.getElementById('textarea')
const editor = new Editor(textarea)
const commandManager = new CommandManager()
bold.addEventListener('mousedown', () => {
commandManager.execute(new BoldCommand(editor))
})
italic.addEventListener('mousedown', () => {
commandManager.execute(new ItalicizeCommand(editor))
})
undo.addEventListener('click', () => {
commandManager.undo()
})
}
Now the editor supports undo for both bold and italic. If you want to add a new feature to the editor that is undoable, you need to create a new command that inherits from EditorCommand
, put the code for that feature in the execute
method, and then call it through the CommandManager
like above.
Thanks for Reading ❤️! Follow me on Twitter for updates about my latest blog posts, video tutorials, and some cool web dev tips. Let's be friends!
Top comments (4)
I really like this example of the command pattern. It's something I have always heard around but not seen many real world examples. Thanks!
Thanks! I'm really glad you liked it :)
I think, that would be better attach code sample because it's complex hold in mind all this files
You are right, thanks for your note. I uploaded the whole source code on GitHub.