Supposed you want to learn React by following React's official documentation. And then you come across the Tic Tac Toe tutorial, you find it interesting and then you want to add some functionality to put it into your portfolio. (btw, If you haven't done it, please do it first, it is a good exercise for learning state management)
One of your ideas may be to add an undo-redo system. The first intuition is just to save all of the states as snapshots and add a pointer so that you can easily go back and forth between snapshots.
As you can see above, we have a history
variable that contains the history of the application's states and we also have currentMove
to track which application state we are currently in.
This is an okayish approach in this case because of the small scope. Unfortunately, this approach doesn't scale well. Imagine you are building a Photoshop, it is not feasible to save all of the states' snapshots as it might cause memory overhead. This is one of the cases that the Command Pattern comes in handy.
Command Pattern
The command pattern is a design pattern in object-oriented programming that enables an application to perform an action by encapsulating the necessary information to do the action in the form of an object. Encapsulating the necessary information enables an application to trigger an action whenever they want.
The Command Pattern consists of three main components: the invoker, the receiver, and the command. The invoker is responsible for invoking the command and is often triggered by a user action. The receiver is the controller of the action that will ultimately perform the action specified by the command. The command itself encapsulates the action to be taken, along with any necessary information.
To learn more about this pattern you can read here and here.
Exploring Command Pattern by Creating Simple Undo-Redo Functionality
In this section, we will focus on building a simple text formatter as you can see below.
The styling of the text in the middle can be changed by clicking the action buttons below. The 3 main style modifiers are bold
, italic
, and underline
. The other two buttons are for undo-redo the style changes.
To implement this using the command pattern, we need to create a base class for the command.
export abstract class Command<T> {
utils: T;
constructor(utils: T) {
this.utils = utils;
}
abstract execute(): void;
abstract undo(): void;
abstract getInfo(): string;
}
This base command list all of the method that must be implemented by each command. If we wanted the command to be applied, then we need to trigger execute
. We can also revert the action by calling undo
. And to make it more intuitive, we can also show the command information by calling getInfo
. As for utils
, it is used to interact with the outer
world.
Let's try implementing bold
command.
export interface CommandUtils {
styles: CSSProperties;
setStyles: Dispatch<React.SetStateAction<CSSProperties>>;
}
export class BoldCommand<T extends CommandUtils> extends Command<T> {
prevFontWeight?: CSSProperties["fontWeight"];
constructor(utils: T) {
super(utils);
this.prevFontWeight = utils.styles.fontWeight;
}
getNextStyle() {
if (
this.prevFontWeight === "bold" ||
(typeof this.prevFontWeight === "number" && this.prevFontWeight >= 700)
) {
return "normal";
}
return "bold";
}
execute() {
const nextFontStyle = this.getNextStyle();
this.utils.setStyles((prevStyles) => ({
...prevStyles,
fontWeight: nextFontStyle,
}));
}
undo() {
this.utils.setStyles((prevStyles) => ({
...prevStyles,
fontWeight: this.prevFontWeight,
}));
}
getInfo() {
return "Bold Command : Change into " + this.getNextStyle();
}
}
As you can see, it is pretty straight forward. When we instantiate the BoldCommand
, we store the previous state. And if we decided to execute the command, we call getNextStyle
to get the next style and apply it to the "outer" world by calling setStyles
from utils
. To revert the changes, we just need to call undo
to apply previous stored state into the current styling.
The ItalicCommand
and UnderlineCommand
are quite similar with the code above. You can see it here or you can do it yourselves as an exercise.
The next that we need is the "receiver", the one that manage the action. In React, we can implement this in many way, but in this article, we will create a custom hook as you can see below
function useHistoryManager<T>() {
const [forwardHistory, setForwardHistory] = useState<Command<T>[]>([]);
const [backHistory, setBackHistory] = useState<Command<T>[]>([]);
const executeCommand = async (command: Command<T>) => {
// clear forward history
setForwardHistory([]);
await command.execute();
setBackHistory((prev) => [...prev, command]);
};
const redo = async () => {
if (!forwardHistory.length) return;
const topRedoCommand = forwardHistory[forwardHistory.length - 1];
await topRedoCommand.execute();
setForwardHistory((prev) => prev.slice(0, -1));
setBackHistory((prev) => [...prev, topRedoCommand]);
};
const undo = async () => {
if (!backHistory.length) return;
const topUndoCommand = backHistory[backHistory.length - 1];
await topUndoCommand.undo();
setBackHistory((prev) => prev.slice(0, -1));
setForwardHistory((prev) => [...prev, topUndoCommand]);
};
const histories = useMemo(() => {
const formattedBackHistory = backHistory.map((command, index) => ({
type: "undo",
command,
message: command.getInfo(),
}));
const formattedForwardHistory = [...forwardHistory]
.reverse()
.map((command, index) => ({
type: "redo",
command,
message: command.getInfo(),
}));
return [...formattedBackHistory, ...formattedForwardHistory];
}, [backHistory.length, forwardHistory.length]);
return {
executeCommand,
redo,
undo,
histories,
};
}
useHistoryManager
manages actions by storing action commands into two places, forwardHistory
and backHistory
. backHistory
stores all of the actions that have already been done. forwardHistory
stores all of the actions that could be done. If we want to introduce new action, we need to call it using executeCommand
.
The last thing that we need to set up is the "invoker". To do this, we can just call our custom context and execute the command based on the API that we already made.
export default function App() {
const [styles, setStyles] = useState<CSSProperties>({});
const utils = { styles, setStyles };
const { executeCommand, redo, undo, histories } =
useHistoryManager<CommandUtils>();
const setTextToItalic = async () => {
const italicCommand = new ItalicCommand(utils);
await executeCommand(italicCommand);
};
const setTextToBold = async () => {
const boldCommand = new BoldCommand(utils);
await executeCommand(boldCommand);
};
const setTextToUnderline = async () => {
const underlineCommand = new UnderlineCommand(utils);
await executeCommand(underlineCommand);
};
return (
<div className={appStyles.container}>
<div className={appStyles.editor}>
<p className={appStyles.mainText} style={styles}>
Hello from react!
</p>
<div className={appStyles.actions}>
<button
className={appStyles.button}
onClick={setTextToItalic}
title="italic"
>
<img src="/assets/format_italic.svg" alt="italic" />
</button>
<button
className={appStyles.button}
onClick={setTextToBold}
title="bold"
>
<img src="/assets/format_bold.svg" alt="bold" />
</button>
<button
className={appStyles.button}
onClick={setTextToUnderline}
title="underline"
>
<img src="/assets/format_underline.svg" alt="underline" />
</button>
<button className={appStyles.button} onClick={undo} title="undo">
<img src="/assets/undo.svg" alt="undo" />
</button>
<button className={appStyles.button} onClick={redo} title="redo">
<img src="/assets/redo.svg" alt="redo" />
</button>
</div>
</div>
<div className={appStyles.history}>
<h2 className={appStyles.historyHeader}>History</h2>
<ol className={appStyles.historyContent}>
<li className={`${appStyles.historyItem}`}>
<button>Initial</button>
</li>
{histories.map((history, index) => (
<li
className={`${appStyles.historyItem} ${
history.type === "undo" ? appStyles.undo : appStyles.redo
}`}
key={index + history.type + history.message}
>
<button>{history.message}</button>
</li>
))}
</ol>
</div>
</div>
);
}
Hooray!, we can create undo-redo using the command pattern. If you want to see this live in action, click here.
Conclusion
With the command pattern, we can simplify action information into a series of objects that can be triggered at a convenient time. But the usage of command pattern is very limited, as this pattern requires a lot of boilerplates that in many cases we don't necessarily need them.
All of the codes above can be found on this repository. If you have any comments or feedback, feel free to leave a comment below. Will be happy to discuss this with you all. Cheers!
Top comments (2)
Nice post!
Thanks!