Hello! Welcome back to our deep dive into advanced RxJS.
In our last lesson, we mastered the optimistic update pattern, learning how to create a snappy UI by assuming success and gracefully rolling back on failure. This was a powerful demonstration of how RxJS can manage complex asynchronous sequences.
Today, we'll tackle another classic and challenging UI problem: implementing undo and redo functionality. This is a feature that users love but can be notoriously difficult to implement with traditional state management. You'll see how RxJS, with its stream-based approach, makes this task surprisingly declarative and manageable.
This lesson directly addresses the learning outcome: Build undo/redo functionality for state changes using the buffer and scan operators. We will explore how to maintain a history of state changes and navigate through it, a pattern that has clear parallels to version control systems or command histories in applications and games.
1. The Core Operators: scan and buffer
To build our undo/redo logic, we'll rely on two key operators. One is already very familiar to you, and the other is new.
scan: The State History Manager
We've used scan extensively to accumulate state, most recently in our Redux-like store and optimistic update patterns. Here, its role is elevated: instead of just tracking the current state, the accumulator in scan will be responsible for maintaining the entire history of our state.
The accumulator will be an object containing:
- The current state value.
- An
undoStack: an array of past states. - A
redoStack: an array of states we've "undone" and can "redo".
Every action—whether it's a new change, an undo, or a redo—will pass through scan, which will compute the next historical snapshot.
buffer: Grouping Emissions
The buffer operator is our new tool. It collects values from a source Observable and emits them as an array whenever a second Observable, the "notifier," emits.

In the context of undo/redo, buffer can be used to group a series of rapid actions into a single "undoable" chunk. For example, you could buffer a series of mousemove events and only commit them to the undo history when the user releases the mouse button. For our purposes today, we'll focus on a pattern where buffer helps manage a queue of commands, which we can then undo.
2. Pattern 1: State-Based Undo/Redo with scan
The most common and robust pattern for undo/redo is to manage a history of state snapshots. Let's design this system first.
Imagine a simple application with a counter. We need three types of actions:
- State Change: Incrementing or decrementing the counter.
- Undo: Reverting to the previous state.
- Redo: Re-applying an undone state.
We can model these as separate streams, merge them, and then use scan to manage the history.
Here is a complete implementation of this pattern.
import { fromEvent, merge, Subject } from 'rxjs';
import { map, scan, startWith, shareReplay, mapTo } from 'rxjs/operators';
// Let's assume we have these buttons in our HTML
const incButton = document.getElementById('increment');
const decButton = document.getElementById('decrement');
const undoButton = document.getElementById('undo');
const redoButton = document.getElementById('redo');
const valueDisplay = document.getElementById('value');
// 1. Define the structure for our history
interface History {
value: number;
undoStack: number[];
redoStack: number[];
}
const initialState: History = {
value: 0,
undoStack: [],
redoStack: [],
};
// 2. Create streams for each user action
const increment$ = fromEvent(incButton, 'click').pipe(mapTo({ type: 'INCREMENT' }));
const decrement$ = fromEvent(decButton, 'click').pipe(mapTo({ type: 'DECREMENT' }));
const undo$ = fromEvent(undoButton, 'click').pipe(mapTo({ type: 'UNDO' }));
const redo$ = fromEvent(redoButton, 'click').pipe(mapTo({ type: 'REDO' }));
// 3. Merge all actions into a single stream
const actions$ = merge(increment$, decrement$, undo$, redo$);
// 4. Use `scan` to manage the state history
const state$ = actions$.pipe(
startWith(initialState),
scan((history: History, action: { type: string }) => {
const { value, undoStack, redoStack } = history;
switch (action.type) {
case 'INCREMENT':
return {
value: value + 1,
undoStack: [...undoStack, value], // Push old state to undo stack
redoStack: [], // A new action clears the redo stack
};
case 'DECREMENT':
return {
value: value - 1,
undoStack: [...undoStack, value],
redoStack: [],
};
case 'UNDO': {
if (undoStack.length === 0) return history; // Nothing to undo
const previousValue = undoStack[undoStack.length - 1];
return {
value: previousValue,
undoStack: undoStack.slice(0, -1), // Pop from undo stack
redoStack: [...redoStack, value], // Push current state to redo stack
};
}
case 'REDO': {
if (redoStack.length === 0) return history; // Nothing to redo
const nextValue = redoStack[redoStack.length - 1];
return {
value: nextValue,
undoStack: [...undoStack, value], // Push current state to undo stack
redoStack: redoStack.slice(0, -1), // Pop from redo stack
};
}
default:
return history;
}
}),
shareReplay(1) // Multicast the state to all subscribers
);
// 5. Subscribe to update the UI
// Update the displayed value
state$.pipe(map(history => history.value)).subscribe(currentValue => {
valueDisplay.textContent = currentValue.toString();
});
// Enable/disable buttons based on stack availability
state$.subscribe(({ undoStack, redoStack }) => {
(undoButton as HTMLButtonElement).disabled = undoStack.length === 0;
(redoButton as HTMLButtonElement).disabled = redoStack.length === 0;
});
This example elegantly handles the entire undo/redo logic within a single, declarative scan operator. Notice how any new action (INCREMENT or DECREMENT) clears the redoStack. This is standard behavior in most applications—once you make a new change, you can no longer "redo" past actions.
3. Pattern 2: Command-Based Undo with buffer and scan
An alternative approach is to manage a history of commands instead of state snapshots. This can be more memory-efficient if your state is large and actions are small, incremental changes.
This is where buffer can come into play. You might buffer a series of commands and then process them. A great example of managing a command queue for undo functionality is discussed in a Stack Overflow thread.
Simulating a command queue and undo stack with RxJS
Let's analyze a clever, purely functional solution for undoing commands from a Stack Overflow answer. This example uses merge and scan in a slightly different way to manage a queue of commands that can be undone.
Please read the accepted answer's edited section, starting with 'Edit: Using only Rx without a mutable array.' Focus on how scan is used. The accumulator acc holds { undoCommand: undefined, q: [] }. Notice how it distinguishes between a regular command and an 'undo' string to either add to the queue (q) or pop from it.
Let's break down the pattern from that resource:
merge(invoked_commands, undo): Two streams are combined. One emits the actual commands to be executed (e.g.,"Command_1","Command_2"), and the other emits a special"undo"string when the undo button is clicked.scan({ undoCommand: undefined, q: [] }, ...): Thescanoperator maintains the history.- The accumulator
accholds the command to be undone (undoCommand) and the queue of past commands (q). - If the incoming
valueis a regular command, it's prepended to the queue:q: [value].concat(acc.q). - If the
valueis"undo", the first command from the queue is moved to theundoCommandproperty, and the rest of the queue is preserved:{ undoCommand: acc.q[0], q: acc.q.slice(1) }.
- The accumulator
.pluck("undoCommand").filter(...): Afterscan, the stream emits the entire accumulator object on every action. Thepluckandfilteroperators are used to ensure that the stream only emits a value when an actual undo needs to happen.
This command-based pattern is powerful, though as the author notes, a bit more convoluted than the state-based approach. The key difference is that it emits the command to be undone, and your application logic must then know how to reverse that specific command. Our first state-based pattern is more direct, as it simply provides the exact previous state to revert to.
4. Encapsulating Logic in a Custom Operator
As you can see, the logic for state history can get verbose. For reusability, we can encapsulate this logic into a custom operator. This is a common practice in advanced RxJS development.
A custom operator is simply a function that takes an Observable as input and returns a new Observable.
The following article explains how to create custom operators. We'll focus on a specific example it provides: a rememberHistory operator that does something very similar to our first undo/redo pattern.
Please read section '4.) Remember History'. Analyze the rememberHistory operator. Notice how it uses scan to accumulate a historyChain of the last N values. This is a simplified version of our undo stack, packaged neatly into a reusable operator.
The rememberHistory operator from the article is a great starting point. It uses scan to keep a running list of the last N emissions. You could extend this concept to create a fully-featured withUndoRedo() custom operator that encapsulates the logic from our first pattern, making it trivial to add this functionality to any state stream in your application.
Conclusion
Today we've added a sophisticated and highly desired feature—undo/redo—to our state management toolkit. You've seen how RxJS provides the building blocks to implement this declaratively, avoiding the tangled mess of callbacks and manual state-pushing that a traditional implementation would require.
Key Takeaways:
scanis the cornerstone of undo/redo logic, acting as the engine that manages the history of states or commands.- The most robust pattern involves storing state snapshots in an accumulator object containing the
value,undoStack, andredoStack. - A new user action should typically clear the
redoStack, establishing a new history timeline. buffercan be used to group commands, which can then be managed by ascan-based undo queue. This is a command-oriented pattern, as opposed to a state-oriented one.- For reusability, this complex logic can be encapsulated within a custom RxJS operator.
Preview of the Next Lesson:
We have now built several powerful state management patterns from scratch using core RxJS operators. Our next step is to bridge this knowledge to the real-world React ecosystem. In the next lesson, we will begin a new module, "Integrating RxJS with Zustand," by learning how to Create and manage a basic Zustand store, covering atomic state updates, actions, and selectors. This will set the stage for combining the power of RxJS streams with a popular, modern state management library.