Undo/Redo stacks
Implementing undo/redo functionality with stacks and the Command design pattern
Introduction
When I first learned about how to implement undo/redo with the command design pattern it felt like magic. Because I didn’t understand a single word. Online tutorials didn’t help too much either. They always showed “hello world” level stuff such as entering a text into an input and then undoing/redoing it with an in-memory array. Unfortunately, my applications have databases, users, and multiple servers. I cannot use an array with strings. And usually, the most important feature is not copying strings but running database queries.
In this post, I’d like to show you a real-world undo/redo setup with stacks, database queries, APIs, and users. We will imitate some of Trello’s features.
At the end of the post, you can find the repository that contains everything.
Stacks
Stack is crucial for implementing undo/redo. It’s a simple LIFO data structure which means that the last element added to the stack is the first one to be removed:
When implementing undo/redo we need to have the changes in LIFO order. For example, if a user does the following things:
Creates a card with the title “Buy milk“
Writes a description: “I really need to buy milk“
Assign a due date 2024-10-01
The history stack should be the following:
So it’s possible to undo these actions in the appropriate order (due date, description, creation).
A stack can be implemented with a simple array:
class Stack {
items = [];
push(item) {
this.items.push(item);
}
pop() {
return this.items.pop();
}
}
const stack = new Stack();
stack.push(1);
stack.push(2);
stack.push(3);
console.log(stack.pop());
console.log(stack.pop());
console.log(stack.pop());
In fact, you don’t even need the Stack class in this case. push() as pop() does the job:
const stack = [];
stack.push(1);
stack.push(2);
stack.push(3);
console.log(stack.pop());
console.log(stack.pop());
console.log(stack.pop());
Stacks with Redis
Since Redis can be used to implement anything I’m going to use it as the stack.
This is the interface:
namespace App\Stacks;
interface Stack
{
public function push(array $event, User $user): void;
public function pop(int $todoId, User $user): array;
}
Just a quick disclaimer: the interface won’t use arrays. I’m going to refactor this later. But for now, it’s much easier to understand.
The stack holds events with data. An event can be something like “description updated” and it holds data such as the todo attributes before and after the change.
To push or pop an item in or out of the stack we need a user. Each history stack belongs to a todo and a user. We don’t want to treat history as global since if I open Trello and change a card this is my personal history. If I click on “Undo” I want to reverse my own stupidity.
To implement this interface with Redis a list can be used. It offers two functions:
lpush
pushes an item to the left side of the list. It’s an append.lpop
pops an item from the left side of the list. It removes the 0th element.
namespace App\Stacks;
class HistoryStack implements Stack
{
public function __construct(private Redis $redis)
{
}
public function push(array $event, User $user): void
{
$this->redis->lPush(
'history:todos:' . $event['data']['todo_id'] . ':' . $user->id,
json_encode($event),
);
}
public function pop(int $todoId, User $user): array
{
$eventJson = $this->redis->lPop(
'history:todos:' . $todoId . ':' . $user->id
);
if (!$eventJson) {
throw new InvalidArgumentException('Not found');
}
return json_decode($eventJson, true);
}
}
It stores the given array as a JSON string. In pop() it decodes the string to an array.
As I said, a history stack belongs to a user and a todo so it has the following index format: history:todos:<todo_id>:<user_id>
Why does the pop() method accept an integer instead of a Todo? Because it’s not guaranteed that the given todo exists in the database when pop() is called. Think about an action such as deleting a todo. When a user wants to undo that, there’s no Todo object to work with.
Simplified Command design pattern
The purpose of the command design pattern is to encapsulate commands into separate objects. Each command such as creating a todo, updating the description, or adding a due date should be its own object. The original design is a little bit more complex than this because it involves four components: command, concrete command, invoker, and receiver. I’m going to leave out the invoker and the receiver in this example because they are not needed for undo/redo.
I’m going to call my commands “actions” since it’s a pretty popular name in the Laravel community.
All actions that need undo/redo have to implement the Undoable interface:
namespace App\Actions;
interface Undoable
{
public function execute(array $data): ?Todo;
public function undo(array $event, User $user): ?Todo;
public function redo(array $event, User $user): ?Todo;
}
Just a quick disclaimer: undo and redo won’t use arrays. I’m going to refactor this later. But for now, it’s much easier to understand.
This is what the StoreTodoAction
looks like:
class StoreTodoAction implements Undoable
{
public function __construct(
private HistoryStack $historyStack,
) {}
public function execute(array $data): Todo
{
$dto = StoreTodoData::from($data);
$todo = Todo::create([
'title' => $dto->title,
'user_id' => $dto->user->id,
]);
$event = [
'action' => self::class,
'data' => [
'todo_id' => $todo->id,
'todo' => [
'before' => null,
'after' => $todo->toArray(),
],
],
];
$this->historyStack->push($event, $dto->user);
return $todo;
}
}
It accepts an array and creates a DTO (DataTransferObject) from it. The DTO is very simple:
readonly class StoreTodoData
{
public function __construct(
public string $title,
public User $user,
) {}
public static function from(array $data): self
{
return new self(
title: $data['title'],
user: $data['user'],
);
}
}
There are going to be more DTOs in this example. All of them follow the same logic so I won’t show you the other ones. You can check them out in the GitHub repository at the end of the post, of course.
Using this DTO the action then creates the Todo. After that, it creates an array and then pushes it onto the history stack. The stack will store it as a JSON string. This is the structure of every event:
{
"action": "App\Actions\StoreTodoAction",
"data": {
"todo_id": 1,
"todo": {
"before": null,
"after": {
"id": 1,
"title": "Buy a new car",
"description": "Or at least spend an hour on the internet browsing for X7 and GLS"
}
}
}
}
The “action” key contains the class name of the action. The “todo” key always has a “before” and “after” key but both can be null. For example, there’s no “before” when creating a new todo and there’s no “after” when deleting one.
Usually, I’d like to avoid working with nested associative arrays because they can’t be type-hinted and are a source of lots of annoying bugs. So I added 3 DTOs to map the array to objects:
One for the top-level indices (action, data). It’s called
Event
One for the data index. It’s called
EventData
And one for the todo index. It’s called
EventDataTodo
This is the top-level object:
namespace App\DataTransferObjects\Event;
readonly class Event
{
public function __construct(
public string $action,
public EventData $data,
) {}
public static function fromJson(string $json): self
{
$data = json_decode($json, true);
return self::fromArray($data);
}
public static function fromArray(array $data): self
{
return new self(
action: $data['action'],
data: EventData::fromArray($data['data']),
);
}
}
DTOs always have these small but useful factory functions. You can check out the other classes in the repository under the App\DataTransferObjects\Event
namespace.
So the action uses the DTO instead of an array:
$event = Event::fromArray([
'action' => self::class,
'data' => [
'todo_id' => $todo->id,
'todo' => [
'before' => null,
'after' => $todo->toArray(),
],
],
]);
$this->historyStack->push($event, $dto->user);
The HistoryStack and the Stack interfaces also use the event DTO:
interface Stack
{
public function push(Event $event, User $user): void;
public function pop(int $todoId, User $user): Event;
}
class HistoryStack implements Stack
{
public function push(Event $event, User $user): void
{
$this->redis->lPush(
'history:todos:' . $event->data->todo_id . ':' . $user->id,
json_encode($event),
);
}
}
So whenever a new Todo is created a new item is pushed onto the Redis stack:
As you can see, the key is history:todos:14:1
where 14 is the Todo ID and 1 is the user ID.
Undo
Now that the history stack is ready let’s do the first undo. We can undo the StoreTodoAction.
Keep reading with a 7-day free trial
Subscribe to Computer Science Simplified to keep reading this post and get 7 days of free access to the full post archives.