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
- Signal-based history provides reactive state management
- Time-travel debugging helps visualize state changes
- Grouping actions prevents history clutter
- Memory management with max history limits
- Flexible API supports various use cases
Next Steps
- Explore State Management Pattern
- Learn about Local Storage Sync
- Read Memory Management Best Practices