Undo and Redo with an Event-Driven Reducer
Undo and redo are hard to bolt on after the fact. When you model state as a sequence of events instead of snapshots, replaying and rewinding the event stack becomes the natural implementation.
The events as source of truth principle pays a dividend that is easy to miss when you first write a reducer.
Undo. It comes for free.
Not completely free -- there is a small amount of extra state to track. But compared to the alternative (storing full state snapshots and diffing them), the event-based approach is so much simpler that it barely registers as extra work.
Why Events Make Undo Natural
A git repository does not store the current state of your files. It stores a sequence of commits. Your current working tree is what you get when you replay those commits from the beginning.
A reducer with an event log works exactly the same way. The current state is the result of applying all events in sequence. To undo the last action, replay all events except the last one.
To redo an undone action, put it back and replay again.
No state diffing. No complex tree patching. The pure reducer function is both the forward engine and the rewind engine.
The State Shape
Two additions to the existing state:
type TripState = {
destinations: Destination[];
activities: Activity[];
// Event log
events: TripAction[]; // applied events (past)
undone: TripAction[]; // popped events (available to redo)
};
const initialState: TripState = {
destinations: [],
activities: [],
events: [],
undone: [],
};The events array is the tape. The undone array is the stack of events that have been popped off the tape but not discarded -- they can be put back.
The Undo Mechanic
Undo removes the last event from events, pushes it onto undone, and recomputes state by replaying the shorter tape.
// Helper: replay all events from scratch
function replay(events: TripAction[]): Pick<TripState, 'destinations' | 'activities'> {
return events.reduce(
(state, event) => applyEvent(state, event),
{ destinations: [], activities: [] }
);
}
// Reducer cases for undo/redo
case 'UNDO': {
if (state.events.length === 0) return state;
const last = state.events[state.events.length - 1];
const shorter = state.events.slice(0, -1);
return {
...replay(shorter),
events: shorter,
undone: [...state.undone, last],
};
}
case 'REDO': {
if (state.undone.length === 0) return state;
const next = state.undone[state.undone.length - 1];
const applied = [...state.events, next];
return {
...replay(applied),
events: applied,
undone: state.undone.slice(0, -1),
};
}applyEvent is the same reducer function you already have, minus the UNDO and REDO cases -- extracted so it can be called in a loop.
Wiring Events Through Normal Transitions
Every real state change also appends to the event log. The pattern is a wrapper:
function tripReducer(state: TripState, action: TripAction): TripState {
switch (action.type) {
case 'DESTINATION_ADDED':
case 'DESTINATION_DELETED':
case 'ACTIVITY_ADDED':
case 'ACTIVITY_TOGGLED': {
const next = applyEvent(
{ destinations: state.destinations, activities: state.activities },
action
);
return {
...state,
...next,
events: [...state.events, action], // record the event
undone: [], // new event clears redo stack
};
}
case 'UNDO': { /* ...as above... */ }
case 'REDO': { /* ...as above... */ }
default: return state;
}
}Note that any real action clears undone. This mirrors standard undo/redo behavior: once you make a new change after undoing, the redo branch is gone.
ExpandEvents timeline: five events stacked left to right, UNDO pops the last event onto the undone stack and recomputes, REDO moves the top of undone back onto events and applies it to current state
Why Not Store State Snapshots?
The alternative approach: every state change pushes a full snapshot onto a history stack. Undo restores the previous snapshot.
// Snapshot approach (avoid this for complex state)
const [history, setHistory] = useState<TripState[]>([initialState]);
const [index, setIndex] = useState(0);
const current = history[index];
function undo() { setIndex(i => Math.max(0, i - 1)); }
function redo() { setIndex(i => Math.min(history.length - 1, i + 1)); }For a text area with a string value, snapshots are fine. For structured state with dozens of destinations and activities, each snapshot is a copy of the entire object graph.
Events are small. They describe what happened -- the action type and its payload.
Ten thousand events representing a complex session might total a few kilobytes. Ten thousand full state snapshots could be many megabytes.
Events also carry intent. A snapshot tells you what state looked like. An event tells you what the user did and when. That distinction matters for debugging, analytics, and audit trails.
What Else Events Give You
Once events are the source of truth, a range of otherwise-hard features become simple:
- Debug replay: reproduce a bug by replaying the exact event sequence that triggered it
- Optimistic updates: apply an event locally, send it to the server, roll it back if the server rejects it (undo the event)
- Collaboration: merge two event logs by sorting events by timestamp and replaying the combined sequence
- Analytics: the event log is an activity stream -- query it directly
None of these require library support. They follow from the same principle: a reducer is a fold over events.
The Essentials
- Undo is replay of events[0..n-1]. Redo is replay of events[0..n-1] + the redone event. No state diffing, no complex patching -- just the same pure reducer function applied to a shorter or longer event tape.
- Events are cheaper than snapshots. An event stores what happened (action type + payload). A snapshot stores the entire state tree. At scale, events win on both memory and signal quality.
- Every real state change clears the redo stack. This matches every undo/redo implementation users already know. Implement it as a convention in the reducer, not as external logic.
Further Reading and Watching
- Event Sourcing -- Martin Fowler: The canonical explanation of why events -- not state -- should be the system of record. The frontend reducer pattern is a direct implementation of this idea.
- Implementing Undo History -- Redux Docs: The Redux approach to undo/redo. The event-log variant described here is simpler when you already have a reducer, but the Redux docs give excellent context on the trade-offs.
- The Immutability Principle -- Fun Fun Function: A clear walkthrough of why immutable updates and replaying history work together. Note: verify this YouTube link before publishing.
Keep reading