State Management advanced

Undo/Redo State Manager with Signals

Implement time-travel debugging and undo/redo functionality using signal-based state management.

Alex Muturi
July 3, 2025
20 min read
#signals #state-management #undo-redo #history #time-travel #debugging

Undo/Redo State Manager with Signals

Implementing undo/redo functionality is essential for many applications, especially editors, form builders, and creative tools. This recipe shows how to build a robust undo/redo system using Angular Signals.

The Problem

Common challenges with undo/redo:

  • Managing state history efficiently
  • Memory constraints with large states
  • Branching timelines (undo then make new change)
  • Grouping related changes
  • Async operations
  • Performance with frequent updates

Complete Solution

import { signal, computed, Signal } from '@angular/core';

export interface HistoryEntry<T> {
  state: T;
  timestamp: number;
  label?: string;
  metadata?: Record<string, any>;
}

export interface UndoRedoOptions {
  maxHistory?: number;
  groupDelayMs?: number;
  enableBranching?: boolean;
}

export class UndoRedoManager<T> {
  private history = signal<HistoryEntry<T>[]>([]);
  private currentIndex = signal(-1);
  private groupTimer: any = null;
  private lastActionTime = 0;

  private readonly maxHistory: number;
  private readonly groupDelayMs: number;
  private readonly enableBranching: boolean;

  // Public computed signals
  readonly currentState = computed<T | null>(() => {
    const index = this.currentIndex();
    const hist = this.history();
    return index >= 0 && index < hist.length ? hist[index].state : null;
  });

  readonly canUndo = computed(() => this.currentIndex() > 0);
  
  readonly canRedo = computed(() => 
    this.currentIndex() < this.history().length - 1
  );

  readonly historySize = computed(() => this.history().length);

  readonly undoCount = computed(() => this.currentIndex());

  readonly redoCount = computed(() => 
    this.history().length - this.currentIndex() - 1
  );

  readonly currentLabel = computed(() => {
    const index = this.currentIndex();
    const hist = this.history();
    return index >= 0 ? hist[index].label : null;
  });

  readonly timeline = computed(() => {
    return this.history().map((entry, index) => ({
      ...entry,
      isCurrent: index === this.currentIndex(),
      index
    }));
  });

  constructor(
    initialState: T,
    options: UndoRedoOptions = {}
  ) {
    this.maxHistory = options.maxHistory ?? 50;
    this.groupDelayMs = options.groupDelayMs ?? 500;
    this.enableBranching = options.enableBranching ?? true;

    // Initialize with first state
    this.pushState(initialState, 'Initial state');
  }

  // Push new state to history
  pushState(state: T, label?: string, metadata?: Record<string, any>): void {
    const now = Date.now();
    const timeSinceLastAction = now - this.lastActionTime;

    // Clear group timer if exists
    if (this.groupTimer) {
      clearTimeout(this.groupTimer);
      this.groupTimer = null;
    }

    // Check if should group with previous action
    const shouldGroup = timeSinceLastAction < this.groupDelayMs && 
                        this.currentIndex() > 0;

    if (shouldGroup && !this.enableBranching) {
      // Replace last entry instead of adding new one
      this.history.update(hist => {
        const newHist = [...hist];
        newHist[this.currentIndex()] = {
          state,
          timestamp: now,
          label: label || hist[this.currentIndex()].label,
          metadata
        };
        return newHist;
      });
    } else {
      // Add new entry
      this.history.update(hist => {
        // If not at end of history, truncate future states
        const currentHist = this.enableBranching 
          ? hist 
          : hist.slice(0, this.currentIndex() + 1);

        const newHist = [
          ...currentHist,
          {
            state,
            timestamp: now,
            label,
            metadata
          }
        ];

        // Enforce max history limit
        if (newHist.length > this.maxHistory) {
          return newHist.slice(-this.maxHistory);
        }

        return newHist;
      });

      this.currentIndex.set(this.history().length - 1);
    }

    this.lastActionTime = now;
  }

  // Undo to previous state
  undo(): T | null {
    if (!this.canUndo()) return null;

    this.currentIndex.update(i => i - 1);
    return this.currentState();
  }

  // Redo to next state
  redo(): T | null {
    if (!this.canRedo()) return null;

    this.currentIndex.update(i => i + 1);
    return this.currentState();
  }

  // Jump to specific point in history
  jumpTo(index: number): T | null {
    if (index < 0 || index >= this.history().length) return null;

    this.currentIndex.set(index);
    return this.currentState();
  }

  // Clear all history
  clear(initialState: T): void {
    this.history.set([{
      state: initialState,
      timestamp: Date.now(),
      label: 'Initial state'
    }]);
    this.currentIndex.set(0);
  }

  // Get state at specific index
  getStateAt(index: number): T | null {
    const hist = this.history();
    return index >= 0 && index < hist.length ? hist[index].state : null;
  }

  // Export history
  exportHistory(): HistoryEntry<T>[] {
    return this.history().map(entry => ({ ...entry }));
  }

  // Import history
  importHistory(history: HistoryEntry<T>[], currentIndex: number): void {
    this.history.set(history);
    this.currentIndex.set(Math.min(currentIndex, history.length - 1));
  }
}

Usage Example: Text Editor

import { Component, signal, effect, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { UndoRedoManager } from './undo-redo-manager';

interface EditorState {
  content: string;
  cursorPosition: number;
  selection?: { start: number; end: number };
}

@Component({
  selector: 'app-text-editor',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule],
  template: `
    <div class="editor-container">
      <div class="toolbar">
        <button 
          (click)="undo()"
          [disabled]="!undoRedo.canUndo()"
          title="Undo (Ctrl+Z)"
        >
          ↶ Undo
        </button>
        
        <button 
          (click)="redo()"
          [disabled]="!undoRedo.canRedo()"
          title="Redo (Ctrl+Y)"
        >
          ↷ Redo
        </button>

        <div class="divider"></div>

        <button (click)="formatBold()" title="Bold (Ctrl+B)">
          <strong>B</strong>
        </button>
        
        <button (click)="formatItalic()" title="Italic (Ctrl+I)">
          <em>I</em>
        </button>

        <div class="divider"></div>

        <button (click)="showHistory.set(!showHistory())" title="Toggle History">
          📜 History
        </button>

        <button (click)="clearEditor()" title="Clear All">
          🗑️ Clear
        </button>

        <div class="status">
          <span>{{ undoRedo.undoCount() }} undo</span>
          <span>{{ undoRedo.redoCount() }} redo</span>
        </div>
      </div>

      <div class="editor-area">
        <textarea
          #textArea
          class="editor"
          [(ngModel)]="content"
          (ngModelChange)="onContentChange($event)"
          (keydown)="onKeyDown($event)"
          placeholder="Start typing..."
          spellcheck="false"
        ></textarea>

        @if (showHistory()) {
          <div class="history-panel">
            <h3>History Timeline</h3>
            <div class="timeline">
              @for (entry of undoRedo.timeline(); track entry.index) {
                <div 
                  class="timeline-entry"
                  [class.current]="entry.isCurrent"
                  (click)="jumpToState(entry.index)"
                >
                  <div class="timeline-marker"></div>
                  <div class="timeline-content">
                    <div class="timeline-label">
                      {{ entry.label || 'Unnamed' }}
                    </div>
                    <div class="timeline-time">
                      {{ formatTime(entry.timestamp) }}
                    </div>
                    <div class="timeline-preview">
                      {{ getPreview(entry.state.content) }}
                    </div>
                  </div>
                </div>
              }
            </div>
          </div>
        }
      </div>

      <div class="info-bar">
        <span>Characters: {{ content().length }}</span>
        <span>Words: {{ wordCount() }}</span>
        <span>Lines: {{ lineCount() }}</span>
        @if (undoRedo.currentLabel()) {
          <span class="current-action">{{ undoRedo.currentLabel() }}</span>
        }
      </div>
    </div>
  `,
  styles: [`
    .editor-container {
      display: flex;
      flex-direction: column;
      height: 600px;
      border: 1px solid #ddd;
      border-radius: 8px;
      overflow: hidden;
    }

    .toolbar {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 12px;
      background: #f5f5f5;
      border-bottom: 1px solid #ddd;
    }

    .toolbar button {
      padding: 8px 16px;
      background: white;
      border: 1px solid #ddd;
      border-radius: 4px;
      cursor: pointer;
      transition: all 0.2s;
    }

    .toolbar button:hover:not(:disabled) {
      background: #e0e0e0;
      transform: translateY(-1px);
    }

    .toolbar button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }

    .divider {
      width: 1px;
      height: 24px;
      background: #ddd;
      margin: 0 4px;
    }

    .status {
      margin-left: auto;
      display: flex;
      gap: 16px;
      font-size: 12px;
      color: #666;
    }

    .editor-area {
      flex: 1;
      display: flex;
      position: relative;
    }

    .editor {
      flex: 1;
      padding: 16px;
      border: none;
      outline: none;
      font-family: 'Monaco', 'Courier New', monospace;
      font-size: 14px;
      line-height: 1.6;
      resize: none;
    }

    .history-panel {
      width: 300px;
      background: #fafafa;
      border-left: 1px solid #ddd;
      overflow-y: auto;
      padding: 16px;
    }

    .history-panel h3 {
      margin: 0 0 16px 0;
      font-size: 14px;
      font-weight: 600;
    }

    .timeline {
      position: relative;
    }

    .timeline::before {
      content: '';
      position: absolute;
      left: 8px;
      top: 0;
      bottom: 0;
      width: 2px;
      background: #ddd;
    }

    .timeline-entry {
      position: relative;
      padding-left: 32px;
      margin-bottom: 20px;
      cursor: pointer;
      transition: opacity 0.2s;
    }

    .timeline-entry:hover {
      opacity: 0.7;
    }

    .timeline-entry.current .timeline-marker {
      background: #2196f3;
      border-color: #2196f3;
      box-shadow: 0 0 0 4px rgba(33, 150, 243, 0.2);
    }

    .timeline-marker {
      position: absolute;
      left: 4px;
      top: 4px;
      width: 10px;
      height: 10px;
      background: white;
      border: 2px solid #999;
      border-radius: 50%;
      transition: all 0.2s;
    }

    .timeline-label {
      font-weight: 500;
      font-size: 12px;
      margin-bottom: 4px;
    }

    .timeline-time {
      font-size: 11px;
      color: #999;
      margin-bottom: 4px;
    }

    .timeline-preview {
      font-size: 11px;
      color: #666;
      font-family: monospace;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    .info-bar {
      display: flex;
      gap: 24px;
      padding: 8px 16px;
      background: #f5f5f5;
      border-top: 1px solid #ddd;
      font-size: 12px;
      color: #666;
    }

    .current-action {
      margin-left: auto;
      color: #2196f3;
      font-weight: 500;
    }
  `]
})
export class TextEditorComponent {
  undoRedo = new UndoRedoManager<EditorState>(
    { content: '', cursorPosition: 0 },
    { maxHistory: 100, groupDelayMs: 1000 }
  );

  content = signal('');
  showHistory = signal(false);

  wordCount = computed(() => {
    const text = this.content().trim();
    return text ? text.split(/\s+/).length : 0;
  });

  lineCount = computed(() => {
    return this.content().split('\n').length;
  });

  constructor() {
    // Sync editor content with undo/redo state
    effect(() => {
      const state = this.undoRedo.currentState();
      if (state) {
        this.content.set(state.content);
      }
    });
  }

  onContentChange(newContent: string) {
    this.undoRedo.pushState(
      { content: newContent, cursorPosition: 0 },
      'Edit text'
    );
  }

  onKeyDown(event: KeyboardEvent) {
    // Ctrl/Cmd + Z = Undo
    if ((event.ctrlKey || event.metaKey) && event.key === 'z' && !event.shiftKey) {
      event.preventDefault();
      this.undo();
    }

    // Ctrl/Cmd + Y or Ctrl/Cmd + Shift + Z = Redo
    if ((event.ctrlKey || event.metaKey) && 
        (event.key === 'y' || (event.key === 'z' && event.shiftKey))) {
      event.preventDefault();
      this.redo();
    }
  }

  undo() {
    this.undoRedo.undo();
  }

  redo() {
    this.undoRedo.redo();
  }

  jumpToState(index: number) {
    this.undoRedo.jumpTo(index);
  }

  formatBold() {
    const current = this.content();
    const newContent = `${current}**bold text**`;
    this.undoRedo.pushState(
      { content: newContent, cursorPosition: 0 },
      'Format bold'
    );
  }

  formatItalic() {
    const current = this.content();
    const newContent = `${current}*italic text*`;
    this.undoRedo.pushState(
      { content: newContent, cursorPosition: 0 },
      'Format italic'
    );
  }

  clearEditor() {
    this.undoRedo.clear({ content: '', cursorPosition: 0 });
  }

  formatTime(timestamp: number): string {
    const now = Date.now();
    const diff = now - timestamp;
    
    if (diff < 1000) return 'Just now';
    if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
    if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
    return `${Math.floor(diff / 3600000)}h ago`;
  }

  getPreview(content: string): string {
    return content.substring(0, 50) || '(empty)';
  }
}

Advanced: Form Builder with Undo/Redo

import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { UndoRedoManager } from './undo-redo-manager';

interface FormField {
  id: string;
  type: 'text' | 'email' | 'number' | 'select';
  label: string;
  required: boolean;
  options?: string[];
}

interface FormBuilderState {
  fields: FormField[];
  formTitle: string;
}

@Component({
  selector: 'app-form-builder',
  standalone: true,
  imports: [FormsModule],
  template: `
    <div class="form-builder">
      <div class="toolbar">
        <button (click)="undo()" [disabled]="!undoRedo.canUndo()">
          ↶ Undo ({{ undoRedo.undoCount() }})
        </button>
        <button (click)="redo()" [disabled]="!undoRedo.canRedo()">
          ↷ Redo ({{ undoRedo.redoCount() }})
        </button>
      </div>

      <div class="builder-area">
        <input
          [(ngModel)]="formTitle"
          (ngModelChange)="updateTitle($event)"
          placeholder="Form Title"
          class="title-input"
        />

        <div class="fields-list">
          @for (field of fields(); track field.id) {
            <div class="field-item">
              <input
                [value]="field.label"
                (input)="updateFieldLabel(field.id, $event)"
                placeholder="Field Label"
              />
              <select
                [value]="field.type"
                (change)="updateFieldType(field.id, $event)"
              >
                <option value="text">Text</option>
                <option value="email">Email</option>
                <option value="number">Number</option>
                <option value="select">Select</option>
              </select>
              <button (click)="removeField(field.id)">Remove</button>
            </div>
          }
        </div>

        <button (click)="addField()">+ Add Field</button>
      </div>
    </div>
  `
})
export class FormBuilderComponent {
  undoRedo = new UndoRedoManager<FormBuilderState>(
    { fields: [], formTitle: 'Untitled Form' },
    { maxHistory: 50 }
  );

  formTitle = signal('Untitled Form');
  fields = signal<FormField[]>([]);

  addField() {
    const newField: FormField = {
      id: `field-${Date.now()}`,
      type: 'text',
      label: 'New Field',
      required: false
    };

    this.undoRedo.pushState(
      {
        fields: [...this.fields(), newField],
        formTitle: this.formTitle()
      },
      'Add field'
    );
  }

  removeField(id: string) {
    this.undoRedo.pushState(
      {
        fields: this.fields().filter(f => f.id !== id),
        formTitle: this.formTitle()
      },
      'Remove field'
    );
  }

  updateFieldLabel(id: string, event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.undoRedo.pushState(
      {
        fields: this.fields().map(f =>
          f.id === id ? { ...f, label: value } : f
        ),
        formTitle: this.formTitle()
      },
      'Update field label'
    );
  }

  updateFieldType(id: string, event: Event) {
    const value = (event.target as HTMLSelectElement).value as FormField['type'];
    this.undoRedo.pushState(
      {
        fields: this.fields().map(f =>
          f.id === id ? { ...f, type: value } : f
        ),
        formTitle: this.formTitle()
      },
      'Change field type'
    );
  }

  updateTitle(title: string) {
    this.undoRedo.pushState(
      {
        fields: this.fields(),
        formTitle: title
      },
      'Update title'
    );
  }

  undo() {
    const state = this.undoRedo.undo();
    if (state) {
      this.fields.set(state.fields);
      this.formTitle.set(state.formTitle);
    }
  }

  redo() {
    const state = this.undoRedo.redo();
    if (state) {
      this.fields.set(state.fields);
      this.formTitle.set(state.formTitle);
    }
  }
}

Testing

import { TestBed } from '@angular/core/testing';
import { UndoRedoManager } from './undo-redo-manager';

describe('UndoRedoManager', () => {
  let manager: UndoRedoManager<string>;

  beforeEach(() => {
    manager = new UndoRedoManager('initial');
  });

  it('should initialize with initial state', () => {
    expect(manager.currentState()).toBe('initial');
    expect(manager.canUndo()).toBe(false);
    expect(manager.canRedo()).toBe(false);
  });

  it('should push and undo states', () => {
    manager.pushState('state1');
    manager.pushState('state2');

    expect(manager.currentState()).toBe('state2');
    expect(manager.canUndo()).toBe(true);

    manager.undo();
    expect(manager.currentState()).toBe('state1');

    manager.undo();
    expect(manager.currentState()).toBe('initial');
  });

  it('should redo states', () => {
    manager.pushState('state1');
    manager.undo();

    expect(manager.canRedo()).toBe(true);
    manager.redo();
    expect(manager.currentState()).toBe('state1');
  });

  it('should clear future on new push after undo', () => {
    manager.pushState('state1');
    manager.pushState('state2');
    manager.undo();
    manager.pushState('state3');

    expect(manager.canRedo()).toBe(false);
    expect(manager.currentState()).toBe('state3');
  });

  it('should respect max history', () => {
    const limited = new UndoRedoManager('initial', { maxHistory: 3 });

    limited.pushState('state1');
    limited.pushState('state2');
    limited.pushState('state3');
    limited.pushState('state4');

    expect(limited.historySize()).toBe(3);
  });
});

Key Takeaways

  1. Signal-based history provides reactive state management
  2. Time-travel debugging helps visualize state changes
  3. Grouping actions prevents history clutter
  4. Memory management with max history limits
  5. Flexible API supports various use cases

Next Steps

References

Share this article