DEV Community

Muhamad Ilyas Mustafa
Muhamad Ilyas Mustafa

Posted on

Creating Undo-Redo System Using Command Pattern in React

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.

preview of the application that we want to build

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Hooray!, we can create undo-redo using the command pattern. If you want to see this live in action, click here.

preview of the application that we want to build

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!

Further Reading

  1. React Tic Tac Toe
  2. Command Pattern - Wikipedia
  3. Command Pattern - Refactoring.guru

Top comments (2)

Collapse
 
paulknulst profile image
Paul Knulst

Nice post!

Collapse
 
mustafamilyas profile image
Muhamad Ilyas Mustafa

Thanks!