How drawDB Implements Undo/Redo Functionality for Diagram Edits

DrawDB uses a stack-based history system where every diagram edit is recorded as an immutable action object containing both forward and reverse payloads, enabling seamless traversal through editing states.

The open-source diagram editor drawDB manages editing history through a React context-driven architecture centered on two arrays: an undoStack and a redoStack. This design allows users to reverse and replay any change—from adding tables to moving fields—with predictable behavior. Let's examine how the implementation works in the actual source code.

Core Architecture: UndoRedoContext and the Stack Model

UndoRedoContext: Global History Storage

The foundation lives in src/context/UndoRedoContext.jsx. This React context provides:

  • undoStack — array of completed actions
  • redoStack — array of reversed actions waiting to be restored
  • setUndoStack and setRedoStack — state setters for mutation
// src/context/UndoRedoContext.jsx
const UndoRedoContext = createContext();

export function UndoRedoProvider({ children }) {
  const [undoStack, setUndoStack] = useState([]);
  const [redoStack, setRedoStack] = useState([]);

  return (
    <UndoRedoContext.Provider
      value={{ undoStack, redoStack, setUndoStack, setRedoStack }}
    >
      {children}
    </UndoRedoContext.Provider>
  );
}

Components access this via useUndoRedo() from src/hooks/useUndoRedo.js, a thin wrapper that avoids direct context imports:

// src/hooks/useUndoRedo.js
import { useContext } from 'react';
import { UndoRedoContext } from '../context/UndoRedoContext';

export const useUndoRedo = () => useContext(UndoRedoContext);

Action and ObjectType Enums

Every recorded change follows a strict schema defined in src/data/constants.js. The Action enum specifies operation types, while ObjectType identifies diagram elements:

// src/data/constants.js (excerpt)
export const Action = {
  ADD: 'ADD',
  MOVE: 'MOVE',
  DELETE: 'DELETE',
  EDIT: 'EDIT',
};

export const ObjectType = {
  TABLE: 'TABLE',
  AREA: 'AREA',
  NOTE: 'NOTE',
  RELATIONSHIP: 'RELATIONSHIP',
  TYPE: 'TYPE',
  ENUM: 'ENUM',
  DBML: 'DBML',
};

These enums ensure consistent payload shapes across the entire codebase.

How Actions Are Recorded

When any component modifies the diagram, it pushes an action object onto undoStack and clears redoStack (new branch in history). The standard action shape is:

{
  action: Action.ADD | Action.MOVE | Action.DELETE | Action.EDIT,
  element: ObjectType.TABLE | ObjectType.AREA | ...,
  id: <elementIdentifier>,           // table ID, note ID, etc.
  data: <forwardPayload>,            // data to re-apply (for redo)
  undo: <reversePayload>,            // data to revert (for undo)
  bulk: <boolean>,                   // true = batch operation
  elements: [<subActions>],          // used when bulk is true
  // element-specific fields: x, y, tid, fid, rid, aid, nid, etc.
}

Recording Example: Adding a Table

Components that add tables push actions like this:

const { setUndoStack } = useUndoRedo();

const addNewTable = (table) => {
  // ... actual table creation logic ...
  
  setUndoStack(prev => [
    ...prev,
    {
      action: Action.ADD,
      element: ObjectType.TABLE,
      data: table,   // full table definition for redo
    },
  ]);
};

Recording Example: Moving a Table

Moves store the previous position as the undo payload:

const moveTable = (id, newX, newY) => {
  const table = tables.find(t => t.id === id);
  const oldPos = { x: table.x, y: table.y };

  updateTable(id, { x: newX, y: newY });

  setUndoStack(prev => [
    ...prev,
    {
      action: Action.MOVE,
      element: ObjectType.TABLE,
      id,
      x: oldPos.x,   // stored for undo
      y: oldPos.y,
    },
  ]);
};

Undo Implementation in ControlPanel.jsx

The actual undo logic resides in src/components/EditorHeader/ControlPanel.jsx. This function inspects the last action, performs the inverse operation, and transfers the action to redoStack.

const undo = () => {
  if (undoStack.length === 0) return;
  const a = undoStack[undoStack.length - 1];
  
  // Remove from undo stack
  setUndoStack(prev => prev.filter((_, i) => i !== prev.length - 1));

  // Handle bulk operations (multiple elements changed at once)
  if (a.bulk) {
    for (const element of a.elements) {
      if (element.type === ObjectType.TABLE) {
        updateTable(element.id, element.undo);
      } else if (element.type === ObjectType.AREA) {
        updateArea(element.id, element.undo);
      } else if (element.type === ObjectType.NOTE) {
        updateNote(element.id, element.undo);
      }
    }
    setRedoStack(prev => [...prev, a]);
    return;
  }

  // Handle DBML snapshot swaps
  if (a.element === ObjectType.DBML) {
    setRedoStack(prev => [...prev, swapDbmlSnapshot(a)]);
    return;
  }

  // Simple actions: ADD, MOVE, DELETE, EDIT
  if (a.action === Action.ADD) {
    // Undo addition → delete the created element
    if (a.element === ObjectType.TABLE) {
      deleteTable(a.data.table.id, false);
    } else if (a.element === ObjectType.AREA) {
      deleteArea(areas[areas.length - 1].id, false);
    } else if (a.element === ObjectType.NOTE) {
      deleteNote(notes[notes.length - 1].id, false);
    } else if (a.element === ObjectType.RELATIONSHIP) {
      deleteRelationship(a.data.relationship.id, false);
    }
    // ... TYPE, ENUM handling ...
    setRedoStack(prev => [...prev, a]);
    
  } else if (a.action === Action.MOVE) {
    // Undo move → restore previous coordinates
    if (a.element === ObjectType.TABLE) {
      const { x, y } = tables.find(t => t.id === a.id);
      setRedoStack(prev => [...prev, { ...a, x, y }]);
      updateTable(a.id, { x: a.x, y: a.y });
    }
    // ... AREA, NOTE handling ...
    
  } else if (a.action === Action.DELETE) {
    // Undo deletion → re-add the removed element
    if (a.element === ObjectType.TABLE) {
      a.data.relationship.forEach(r => addRelationship(r, false));
      addTable(a.data, false);
    }
    // ... other element types ...
    setRedoStack(prev => [...prev, a]);
    
  } else if (a.action === Action.EDIT) {
    // Undo edit → apply stored reverse payload
    if (a.element === ObjectType.TABLE) {
      if (a.component === "field") {
        updateField(a.tid, a.fid, a.undo);
      }
      // ... other components: field_add, index, unique_constraint, etc.
    } else if (a.element === ObjectType.RELATIONSHIP) {
      updateRelationship(a.rid, a.undo);
    }
    // ... AREA, NOTE, TYPE, ENUM handling ...
    setRedoStack(prev => [...prev, a]);
  }
};

Key implementation details:

  • false parameter — prevents creating new undo entries during reversal (avoiding infinite loops)
  • Coordinate preservation — moves capture current position before restoring old position, enabling redo
  • Relationship handling — table deletions store associated relationships, which are restored together

Redo Implementation: Mirror of Undo

The redo function in the same file applies forward operations from redoStack back onto undoStack:

const redo = () => {
  if (redoStack.length === 0) return;
  const a = redoStack[redoStack.length - 1];
  
  setRedoStack(prev => prev.filter((_, i) => i !== prev.length - 1));

  if (a.bulk) {
    a.elements.forEach(el => {
      if (el.type === ObjectType.TABLE) addTable(el.data, false);
      if (el.type === ObjectType.AREA) addArea(el.data, false);
      if (el.type === ObjectType.NOTE) addNote(el.data, false);
    });
    setUndoStack(prev => [...prev, a]);
    return;
  }

  if (a.element === ObjectType.DBML) {
    setUndoStack(prev => [...prev, swapDbmlSnapshot(a)]);
    return;
  }

  if (a.action === Action.ADD) {
    if (a.element === ObjectType.TABLE) addTable(a.data, false);
    if (a.element === ObjectType.AREA) addArea(a.data, false);
    if (a.element === ObjectType.NOTE) addNote(a.data, false);
    setUndoStack(prev => [...prev, a]);
    
  } else if (a.action === Action.MOVE) {
    if (a.element === ObjectType.TABLE) {
      updateTable(a.id, { x: a.x, y: a.y });
    }
    // ... AREA, NOTE ...
    setUndoStack(prev => [...prev, a]);
    
  } else if (a.action === Action.DELETE) {
    if (a.element === ObjectType.TABLE) deleteTable(a.data.id, false);
    setUndoStack(prev => [...prev, a]);
    
  } else if (a.action === Action.EDIT) {
    if (a.element === ObjectType.TABLE) {
      if (a.component === "field") updateField(a.tid, a.fid, a.data);
    }
    setUndoStack(prev => [...prev, a]);
  }
};

Notice the symmetry: undo uses a.undo, redo uses a.data (or a.x, a.y for moves).

Integration with the UI

The undo/redo buttons in ControlPanel.jsx wire directly to these functions:

import { useUndoRedo } from '../../hooks/useUndoRedo';

export default function ControlPanel() {
  const { undoStack, redoStack } = useUndoRedo();

  return (
    <>
      <button 
        onClick={undo} 
        disabled={undoStack.length === 0}
      >
        Undo
      </button>
      <button 
        onClick={redo} 
        disabled={redoStack.length === 0}
      >
        Redo
      </button>
    </>
  );
}

Special Cases: Bulk Operations and DBML Snapshots

Bulk actions handle multiple elements in one history entry. The elements array contains sub-actions with their own undo payloads, processed in sequence.

DBML snapshots use swapDbmlSnapshot(a), which exchanges the current diagram state with a stored previous state—treated as atomic operations in the history.

Summary

  • drawDB implements undo/redo through two React state arrays (undoStack, redoStack) provided by UndoRedoContext in src/context/UndoRedoContext.jsx
  • Every edit creates an immutable action object with standardized fields from Action and ObjectType enums defined in src/data/constants.js
  • The undo and redo functions in src/components/EditorHeader/ControlPanel.jsx interpret action types to apply inverse or forward operations using diagram-mutating APIs
  • Stack transfer is atomic: popping from one stack always pushes to the opposite, preserving history continuity
  • Special handling exists for bulk edits, DBML snapshots, and relationship restoration during table deletions

Frequently Asked Questions

What data structure does drawDB use for undo/redo history?

DrawDB uses two simple JavaScript arrays managed through React state. The undoStack stores completed actions; the redoStack stores actions that were undone. This stack-based approach keeps the implementation straightforward while supporting infinite traversal through edit history.

Where is the undo/redo logic actually implemented?

The core logic lives in src/components/EditorHeader/ControlPanel.jsx, specifically in the undo() and redo() functions. These functions consume the UndoRedoContext from src/context/UndoRedoContext.jsx and invoke diagram-mutating utilities like deleteTable(), updateField(), and addRelationship().

How does drawDB prevent creating new history entries when undoing?

All diagram-mutation functions accept a false parameter as their last argument. When false, these functions skip pushing to the undo stack. The undo and redo functions pass false to every mutation, ensuring that reversal operations don't generate duplicate history entries.

Can drawDB undo complex operations like bulk table moves?

Yes. DrawDB's action object includes a bulk boolean and elements array for batch operations. When bulk is true, the undo/redo functions iterate through all sub-elements and apply the appropriate inverse or forward operation to each, treating the entire batch as single history entry.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →