Skip to the solution
MediumFrontend12 min read

Build an Undoable Counter (undo/redo history)

1.0k views

The problem

Question presented to candidate: "Build a counter that starts at 0 with four buttons: divide by 2, minus 1, plus 1 and times 2. Every operation should be undoable and redoable, and below the counter show a history table of what was done, with the old and new values. If I undo a few steps and then press a new operation, the steps I undid should no longer be redoable. Add Reset, and keyboard shortcuts if you have time."

Requirements:

  • A value starting at 0 and four operation buttons: /2, -1, +1, x2.
  • Undo reverts the latest operation; Redo re-applies the latest undone one. Both are disabled when there is nothing to do.
  • A new operation after an undo clears the redo history.
  • A history table, newest first, showing the operation, the old value and the new value.
  • Reset clears the value and all history.
  • Bonus: Ctrl+Z / Cmd+Z to undo, Ctrl+Shift+Z or Ctrl+Y to redo; a cap on history length.

What a strong answer should cover:

  • The past / present / future model, kept in one state object so the three can never drift apart.
  • Undo moves the newest past entry to the front of the future; redo moves it back; a new action pushes to the past and empties the future.
  • Storing operations with their before and after values (or snapshots of the value), and why that makes undo exact even for /2.
  • Pure transition functions that are trivial to unit-test, and immutable updates.
  • Keyboard shortcuts on the window with cleanup, and disabling Undo / Redo when their stacks are empty.

Clarifying questions expected:

  • "Should Reset itself be undoable?"
  • "Is there a limit on how much history to keep?"
  • "Should /2 produce fractions, or round to an integer?"

Code / implementation expected: Yes. A working component with the undo/redo logic as pure functions, in about 30 minutes.

  • State
  • History
  • Undo

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

12 min read ยท 9 sections

React solution

Every file of the working solution. Open it in the playground to run and change it.

ReactComplete solution
import { useEffect, useState } from "react";
import HistoryTable from "./src/components/HistoryTable";
import {
  OPERATIONS,
  canRedo,
  canUndo,
  initialHistory,
  perform,
  redo,
  shortcutFor,
  undo,
} from "./src/utils/history";

export default function App() {
  // One state object holds all three stacks, so they can never get out of step.
  const [history, setHistory] = useState(initialHistory);

  // Keyboard shortcuts. The listener only
  // calls the setter with a pure function,
  // so it never needs the current history and can be registered once.
  useEffect(() => {
    function onKeyDown(e) {
      const action = shortcutFor(e);
      if (!action) return;
      e.preventDefault();
      setHistory(action === "undo" ? undo : redo);
    }
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, []);

  return (
    <main className="undo-app">
      <h1>Undoable Counter</h1>
      <div className="toolbar">
        <button
          type="button"
          data-testid="undo"
          disabled={!canUndo(history)}
          onClick={() => setHistory(undo)}
        >
          Undo
        </button>
        <button
          type="button"
          data-testid="redo"
          disabled={!canRedo(history)}
          onClick={() => setHistory(redo)}
        >
          Redo
        </button>
        <button
          type="button"
          data-testid="reset"
          onClick={() => setHistory(initialHistory())}
        >
          Reset
        </button>
      </div>
      <output className="value" data-testid="value" aria-live="polite">
        {history.present}
      </output>
      <div className="ops">
        {OPERATIONS.map((op) => (
          <button
            key={op.label}
            type="button"
            data-testid={"op-" + op.label}
            onClick={() => setHistory((h) => perform(h, op))}
          >
            {op.label}
          </button>
        ))}
      </div>
      <HistoryTable past={history.past} />
    </main>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 33 of 107 in Machine coding

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

Build a Nested Comments widget

Continue