Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Implementing Undo/Redo with `buffer` and `scan`

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.

This diagram shows `buffer` collecting items from the source stream (top) into a bundle. This bundle is emitted as an array (bottom) only when the notifier stream (middle) sends a signal.

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:

  1. State Change: Incrementing or decrementing the counter.
  2. Undo: Reverting to the previous state.
  3. 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:

  1. 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.
  2. scan({ undoCommand: undefined, q: [] }, ...): The scan operator maintains the history.
    • The accumulator acc holds the command to be undone (undoCommand) and the queue of past commands (q).
    • If the incoming value is a regular command, it's prepended to the queue: q: [value].concat(acc.q).
    • If the value is "undo", the first command from the queue is moved to the undoCommand property, and the rest of the queue is preserved: { undoCommand: acc.q[0], q: acc.q.slice(1) }.
  3. .pluck("undoCommand").filter(...): After scan, the stream emits the entire accumulator object on every action. The pluck and filter operators 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.

Create Custom RxJs Operators

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:

  • scan is 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, and redoStack.
  • A new user action should typically clear the redoStack, establishing a new history timeline.
  • buffer can be used to group commands, which can then be managed by a scan-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.

Can't find a good explanation? Sign up and we'll make it for you

Sign up