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
| Feature | Client-Side | Server-Side | Hybrid |
|---|---|---|---|
| Latency | Instant | Network-dependent | Instant + sync |
| Persistence | Session only | Permanent | Configurable |
| Complexity | Low | High | Medium |
| Collaboration | No | Yes | Limited |
| Offline | Yes | No | Yes (with sync) |
| Memory | Browser-limited | Server-managed | Browser + 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