How TAFNE's Undo/Redo Works: A Case Study in HTML Snapshot Stacks
Undo is one of those features that users never consciously appreciate. They only notice when it doesn't work.
Building a reliable undo system for a browser table editor turned out to be more interesting than I expected. The data structure is straightforward. The details that make it correct are not.
The Data Structure
TAFNE's undo system uses a class called TableHistoryManager. The core is a plain JavaScript array and an integer index.
class TableHistoryManager {
constructor(maxHistory = 50) {
this.history = [];
this.currentIndex = -1;
this.maxHistory = 50;
this.isRestoring = false;
}
}
The history array holds states. The currentIndex is a pointer to the active state. At startup it's -1, meaning no states exist yet.
This is a stack implemented on top of an array, but with a cursor. Pure push/pop stacks only let you access the top. The cursor pattern lets you navigate backward (undo) and forward (redo) without destroying states until you need to.
Saving State
Every table mutation calls saveCurrentState(), which captures the full innerHTML of the table container and calls saveState(tableHtml) on the manager.
saveState(tableHtml) {
if (this.isRestoring) return;
if (!tableHtml || tableHtml.trim() === '') return;
if (this.currentIndex >= 0 && this.history[this.currentIndex] === tableHtml) return;
this.history = this.history.slice(0, this.currentIndex + 1); this.history.push(tableHtml);
if (this.history.length > this.maxHistory) { this.history.shift(); } else { this.currentIndex++; } }
Three guards before any state is saved. The first is the most important one: if (this.isRestoring) return. The restore function temporarily writes HTML to the DOM and reinitializes features. Some of those initialization paths call saveCurrentState() as a side effect. Without this guard, restoration would immediately push a new state on top of itself, creating a loop that would corrupt the history stack.
The second guard skips empty states. The third skips states that are identical to the current one, so no-ops don't bloat the history.
Then comes the branch-cutting rule:
this.history = this.history.slice(0, this.currentIndex + 1);
If you undo three steps and then make a new edit, the three "future" states are discarded before the new state is pushed. This is standard undo behavior, once you branch off a new timeline, the old timeline is gone. Most users never notice this happens, but if it didn't, the history could contain contradictory branching states that would be impossible to navigate correctly.
The maxHistory cap uses shift() to remove the oldest entry when the limit is reached, keeping the most recent 50 states.
Navigating History
Undo and redo just move the pointer:
undo() {
if (this.canUndo()) {
this.currentIndex--;
return this.history[this.currentIndex];
}
return null;
}
redo() { if (this.canRedo()) { this.currentIndex++; return this.history[this.currentIndex]; } return null; }
Neither method modifies the array. Both return the state at the new index, or null if the boundary is hit. The calling code handles null by showing a "Nothing to undo" toast.
canUndo() is this.currentIndex > 0. canRedo() is this.currentIndex < this.history.length - 1.
Restoring
The restore function is where the isRestoring guard matters most:
function performUndo() {
const state = window.historyManager.undo();
if (state) {
window.historyManager.isRestoring = true;
$('#tableContainer').html(state);
restoreActiveTable(activeId);
window.initializeAllFeatures();
window.setupTableInteraction();
window.historyManager.isRestoring = false;
}
}
The flag wraps the entire restoration sequence. Any save attempts during that window, from feature initialization hooks, are silently ignored. Once isRestoring returns to false, the system is live again.
The Tradeoff: Full HTML Snapshots
The state stored in history is the full innerHTML string of the container element. Not a diff. Not a JSON delta. The entire HTML.
For a table with 50 rows and 10 columns, that's roughly 10-20KB per state. With 50 states in history, that's 500KB to 1MB of memory for undo. On any modern device, that's negligible.
The benefit is simplicity. Restoring a state is one DOM write: $('#tableContainer').html(state). No delta application, no conflict resolution, no partial-state handling. The HTML is the state. Write it back, reinitialize, done.
If performance ever becomes a concern for very large tables, the history system could be replaced with a diff-based approach. The interface is narrow enough, saveState(), undo(), redo(): that the swap would be surgical. But for now, the simple approach is correct and fast enough.
The Multi-Table Case
TAFNE supports multiple sheets. Each sheet is a separate table in the container. The undo system captures the state of the entire container, not individual tables, which means a single undo operation reverts all tables to their previous state simultaneously.
This is a coarse-grained approach, but it's consistent. There's no partial undo where one sheet goes back and another doesn't. The tradeoff is that container-level operations (like adding a new sheet) are undoable too, which is the correct behavior.
The source is open: github.com/carnworkstudios/TAFNE