Asher Cohen
Back to posts

Redux Undo/Redo: Client-Side State History

Complete guide to implementing undo/redo in Redux — patterns, tradeoffs, and when to use client vs server history

Introduction

Undo/redo is a powerful feature that lets users reverse and replay actions. In Redux applications, you have two fundamental approaches: client-side history (using libraries like redux-undo) or server-side history. Understanding the tradeoffs helps you choose the right approach for your use case.

Client vs Server History

The Core Decision

Undo/redo either happens 100% on the server or 100% on the client. Mixing the two creates complex synchronization problems.

// Client-side: history lives in Redux store
// - Instant undo/redo
// - Works offline
// - Lost on page refresh (unless persisted)

// Server-side: history lives in database
// - Persistent across sessions
// - Supports collaboration
// - Requires API calls for each undo/redo

Think of a Text Editor

A text editor has:

  • In-memory state — current document content
  • In-memory history — undo/redo stack
  • Save to disk — periodic persistence, not on every undo/redo

Once you need features like collaborative editing or cross-device sync, you'll need to manually manage state and only periodically sync — or your server needs native undo/redo support.

Client-Side Undo/Redo with redux-undo

Installation

npm install redux-undo

Basic Setup

import undoable from 'redux-undo';

// Wrap your reducer
const counterReducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT': return state + 1;
    case 'DECREMENT': return state - 1;
    default: return state;
  }
};

const undoableCounter = undoable(counterReducer);

// State shape becomes:
// {
//   past: [...previousStates],
//   present: currentState,
//   future: [...futureStates]
// }

// Dispatch undo/redo
import { ActionCreators } from 'redux-undo';

store.dispatch(ActionCreators.undo());
store.dispatch(ActionCreators.redo());

Configuration Options

const undoableCounter = undoable(counterReducer, {
  limit: 20,              // Max history steps
  filter: (action) => {   // Which actions to track
    return action.type !== 'UPDATE_TIMESTAMP';
  },
  groupBy: (action) => {  // Group rapid actions
    return action.type === 'TEXT_CHANGE' ? 'text' : null;
  },
  undoType: 'MY_UNDO',    // Custom action types
  redoType: 'MY_REDO',
  clearHistoryType: 'CLEAR_HISTORY',
  
  // Initial history
  initialState: undefined,
  
  // Debug mode
  debug: false
});

Filtering Actions

// Only track meaningful state changes
const undoableTodos = undoable(todosReducer, {
  filter: function excludeActions(action) {
    // Don't track UI-only actions
    if (action.type.startsWith('UI/')) return false;
    
    // Don't track loading states
    if (action.type.endsWith('/LOADING')) return false;
    
    // Don't track analytics
    if (action.type === 'TRACK_EVENT') return false;
    
    return true; // Track everything else
  }
});

Grouping Rapid Changes

// Group text input changes into one undo step
const undoableEditor = undoable(editorReducer, {
  groupBy: (action, currentState, previousHistory) => {
    // Group consecutive TEXT_CHANGE actions
    if (action.type === 'TEXT_CHANGE') {
      return 'text_edit';
    }
    return null; // Don't group other actions
  }
});

// User types "hello" → one undo step, not five

Manual Undo/Redo Implementation

For more control, implement undo/redo manually:

class UndoRedoManager {
  constructor(initialState, maxHistory = 50) {
    this.history = [{ state: initialState, action: null }];
    this.currentIndex = 0;
    this.maxHistory = maxHistory;
  }
  
  push(state, action) {
    // Discard any future states when new action occurs
    this.history = this.history.slice(0, this.currentIndex + 1);
    
    // Add new state
    this.history.push({ state, action });
    
    // Enforce history limit
    if (this.history.length > this.maxHistory) {
      this.history.shift();
    } else {
      this.currentIndex++;
    }
  }
  
  undo() {
    if (this.currentIndex > 0) {
      this.currentIndex--;
      return this.history[this.currentIndex].state;
    }
    return null;
  }
  
  redo() {
    if (this.currentIndex < this.history.length - 1) {
      this.currentIndex++;
      return this.history[this.currentIndex].state;
    }
    return null;
  }
  
  canUndo() {
    return this.currentIndex > 0;
  }
  
  canRedo() {
    return this.currentIndex < this.history.length - 1;
  }
  
  getCurrentState() {
    return this.history[this.currentIndex].state;
  }
}

// Usage with Redux middleware
const undoRedoMiddleware = (manager) => (store) => (next) => (action) => {
  const previousState = store.getState();
  const result = next(action);
  const nextState = store.getState();
  
  if (previousState !== nextState) {
    manager.push(nextState, action);
  }
  
  return result;
};

Server-Side Undo/Redo

Event Sourcing Pattern

// Each action is an event stored in the database
const events = [
  { type: 'TEXT_INSERTED', position: 0, text: 'Hello', timestamp: 1000 },
  { type: 'TEXT_DELETED', position: 4, length: 1, timestamp: 2000 },
  { type: 'TEXT_INSERTED', position: 4, text: 'o World', timestamp: 3000 }
];

// Rebuild state by replaying events
function rebuildState(events) {
  let state = '';
  for (const event of events) {
    switch (event.type) {
      case 'TEXT_INSERTED':
        state = state.slice(0, event.position) + event.text + state.slice(event.position);
        break;
      case 'TEXT_DELETED':
        state = state.slice(0, event.position) + state.slice(event.position + event.length);
        break;
    }
  }
  return state;
}

// Undo = remove last event and rebuild
function undo(events) {
  return rebuildState(events.slice(0, -1));
}

Command Pattern

// Each command knows how to execute and undo itself
class InsertTextCommand {
  constructor(position, text) {
    this.position = position;
    this.text = text;
  }
  
  execute(state) {
    return state.slice(0, this.position) + this.text + state.slice(this.position);
  }
  
  undo(state) {
    return state.slice(0, this.position) + state.slice(this.position + this.text.length);
  }
}

class DeleteTextCommand {
  constructor(position, length) {
    this.position = position;
    this.length = length;
    this.deletedText = null;
  }
  
  execute(state) {
    this.deletedText = state.slice(this.position, this.position + this.length);
    return state.slice(0, this.position) + state.slice(this.position + this.length);
  }
  
  undo(state) {
    return state.slice(0, this.position) + this.deletedText + state.slice(this.position);
  }
}

Hybrid Approach: Periodic Sync

class HybridUndoRedo {
  constructor(initialState, syncInterval = 5000) {
    this.clientHistory = new UndoRedoManager(initialState);
    this.lastSyncTime = Date.now();
    this.syncInterval = syncInterval;
    this.isDirty = false;
  }
  
  push(state, action) {
    this.clientHistory.push(state, action);
    this.isDirty = true;
    
    // Sync if interval has passed
    if (Date.now() - this.lastSyncTime > this.syncInterval) {
      this.syncToServer(state);
    }
  }
  
  async syncToServer(state) {
    try {
      await fetch('/api/state/sync', {
        method: 'POST',
        body: JSON.stringify(state)
      });
      this.lastSyncTime = Date.now();
      this.isDirty = false;
    } catch (error) {
      console.error('Sync failed:', error);
    }
  }
  
  // Force sync on specific user actions
  async forceSync() {
    await this.syncToServer(this.clientHistory.getCurrentState());
  }
}

Best Practices

Do's

// ✅ Limit history size to prevent memory issues
undoable(reducer, { limit: 50 });

// ✅ Filter out non-state-changing actions
undoable(reducer, {
  filter: (action) => !action.type.startsWith('UI/')
});

// ✅ Group rapid changes
undoable(reducer, {
  groupBy: (action) => action.type === 'DRAG' ? 'drag' : null
});

// ✅ Clear history on significant state resets
store.dispatch(ActionCreators.clearHistory());

// ✅ Provide visual feedback for undo/redo availability
const canUndo = state.past.length > 0;
const canRedo = state.future.length > 0;

Don'ts

// ❌ Don't track every tiny change
// Group or filter rapid input changes

// ❌ Don't mix client and server history
// Choose one approach and stick with it

// ❌ Don't store huge state snapshots
// Consider storing diffs instead of full state

// ❌ Don't forget to clear history on navigation
// Reset history when user leaves a context

// ❌ Don't track side effects in history
// API calls, analytics, logging shouldn't be undoable

When to Use Each Approach

Client-Side (redux-undo)

Best for:

  • ✅ Single-user applications
  • ✅ Rich text editors
  • ✅ Drawing/design tools
  • ✅ Form-heavy applications
  • ✅ Offline-first apps

Tradeoffs:

  • ❌ Lost on page refresh (without persistence)
  • ❌ Memory usage grows with history
  • ❌ No collaboration support

Server-Side

Best for:

  • ✅ Collaborative applications
  • ✅ Cross-device sync
  • ✅ Audit trail requirements
  • ✅ Complex business logic
  • ✅ Compliance requirements

Tradeoffs:

  • ❌ Network latency on undo/redo
  • ❌ More complex implementation
  • ❌ Server costs for history storage

Hybrid (Periodic Sync)

Best for:

  • ✅ Apps needing both instant feedback and persistence
  • ✅ Progressive web apps
  • ✅ Applications with occasional connectivity

Tradeoffs:

  • ❌ Conflict resolution complexity
  • ❌ Sync timing decisions
  • ❌ Potential data loss window

Summary Table

FeatureClient-SideServer-SideHybrid
LatencyInstantNetwork-dependentInstant + sync
PersistenceSession onlyPermanentConfigurable
ComplexityLowHighMedium
CollaborationNoYesLimited
OfflineYesNoYes (with sync)
MemoryBrowser-limitedServer-managedBrowser + server

Conclusion

Undo/redo is a powerful UX feature that requires careful architectural decisions:

  • Client-side (redux-undo) is great for single-user, rich interaction apps
  • Server-side (event sourcing/commands) enables collaboration and persistence
  • Hybrid (periodic sync) balances instant feedback with durability

Start simple with client-side undo/redo. If you need collaboration or cross-device sync, invest in server-side history. The key is choosing one approach and being consistent — mixing client and server history creates more problems than it solves.

#redux #undo-redo #state-management #react #patterns #web-development