Skip to the code
cf.

useUndo

Write useUndo(initialValue) and export it from useUndo.js. The tea shop's label editor uses it to undo and redo changes to a tea's name. It returns { value, set, undo, redo, canUndo, canRedo }.

set(next) makes next the value and remembers the old one, so undo() can go back to it. Like a state setter, set also takes an updater, set((current) => next), and two calls in the same event are two steps. Setting the value it already has (compared with Object.is) changes nothing and adds no step.

undo() steps back one value and redo() steps forward again. When there's nothing to undo or redo, they do nothing. canUndo and canRedo say whether there is. A new set after an undo starts a new branch, so the values you could have redone are gone.

set, undo, and redo are the same functions on every render.

Keep the history in a reducer, and export it too: undoReducer(state, action). state is { past, present, future }, where past is an array of earlier values, oldest first, and future is an array of the values redo goes to, nearest first. An action is { type: "set", next }, { type: "undo" }, or { type: "redo" }. The reducer must not change the state it's given, and when an action changes nothing it returns that same state, so React can skip the render.

undoReducer({ past: ["Green"], present: "Green tea", future: [] }, { type: "undo" });
// { past: [], present: "Green", future: ["Green tea"] }
 
function LabelEditor() {
  const { value, set, undo, redo, canUndo, canRedo } = useUndo("");
  return (
    <div>
      <label>
        Tea name <input value={value} onChange={(event) => set(event.target.value)} />
      </label>
      <button onClick={undo} disabled={!canUndo}>Undo</button>
      <button onClick={redo} disabled={!canRedo}>Redo</button>
    </div>
  );
}

The Preview shows Demo.jsx, a label editor that uses your hook.

What the tests check

  1. starts with the initial value and nothing to undo or redo
    const { result } = renderHook(() => useUndo("Sencha"));
    expect(result.current.value).toBe("Sencha");
    expect(result.current.canUndo).toBe(false);
    expect(result.current.canRedo).toBe(false);
    
  2. undo steps back one value at a time, then stops
    const { result } = renderHook(() => useUndo("G"));
    act(() => result.current.set("Gr"));
    act(() => result.current.set("Gre"));
    expect(result.current.value).toBe("Gre");
    expect(result.current.canUndo).toBe(true);
    act(() => result.current.undo());
    expect(result.current.value).toBe("Gr");
    act(() => result.current.undo());
    expect(result.current.value).toBe("G");
    expect(result.current.canUndo).toBe(false);
    act(() => result.current.undo());
    expect(result.current.value).toBe("G");
    
  3. redo steps forward again, then stops
    const { result } = renderHook(() => useUndo("G"));
    act(() => result.current.set("Gr"));
    act(() => result.current.set("Gre"));
    act(() => result.current.undo());
    act(() => result.current.undo());
    expect(result.current.canRedo).toBe(true);
    act(() => result.current.redo());
    expect(result.current.value).toBe("Gr");
    act(() => result.current.redo());
    expect(result.current.value).toBe("Gre");
    expect(result.current.canRedo).toBe(false);
    act(() => result.current.redo());
    expect(result.current.value).toBe("Gre");
    
  4. a new set after an undo clears what you could redo
    const { result } = renderHook(() => useUndo("Green"));
    act(() => result.current.set("Green tea"));
    act(() => result.current.undo());
    act(() => result.current.set("Sencha"));
    expect(result.current.value).toBe("Sencha");
    expect(result.current.canRedo).toBe(false);
    act(() => result.current.redo());
    expect(result.current.value).toBe("Sencha");
    act(() => result.current.undo());
    expect(result.current.value).toBe("Green");
    
  5. setting the value it already has adds no step
    const { result } = renderHook(() => useUndo("Sencha"));
    act(() => result.current.set("Sencha"));
    expect(result.current.canUndo).toBe(false);
    
  6. takes an updater, and two in one event are two steps
    const { result } = renderHook(() => useUndo(0));
    const { set } = result.current;
    act(() => {
      set((cups) => cups + 1);
      set((cups) => cups + 1);
    });
    expect(result.current.value).toBe(2);
    act(() => result.current.undo());
    expect(result.current.value).toBe(1);
    act(() => result.current.undo());
    expect(result.current.value).toBe(0);
    
  7. set, undo, and redo are the same functions on every render
    const { result } = renderHook(() => useUndo("G"));
    const { set, undo, redo } = result.current;
    act(() => set("Gr"));
    act(() => undo());
    act(() => redo());
    expect(result.current.set).toBe(set);
    expect(result.current.undo).toBe(undo);
    expect(result.current.redo).toBe(redo);
    
  8. undoReducer moves values between past, present, and future
    const start = { past: ["Green"], present: "Green tea", future: [] };
    const undone = undoReducer(start, { type: "undo" });
    expect(undone).toEqual({ past: [], present: "Green", future: ["Green tea"] });
    expect(undoReducer(undone, { type: "redo" })).toEqual(start);
    expect(undoReducer(undone, { type: "set", next: "Sencha" })).toEqual({ past: ["Green"], present: "Sencha", future: [] });
    const deep = { past: ["a", "b"], present: "c", future: ["d", "e"] };
    expect(undoReducer(deep, { type: "undo" })).toEqual({ past: ["a"], present: "b", future: ["c", "d", "e"] });
    expect(undoReducer(deep, { type: "redo" })).toEqual({ past: ["a", "b", "c"], present: "d", future: ["e"] });
    
  9. undoReducer doesn't change its input, and returns it when nothing changes
    const state = Object.freeze({ past: Object.freeze(["a", "b"]), present: "c", future: Object.freeze(["d"]) });
    undoReducer(state, { type: "set", next: "e" });
    undoReducer(state, { type: "undo" });
    undoReducer(state, { type: "redo" });
    expect(state).toEqual({ past: ["a", "b"], present: "c", future: ["d"] });
    const empty = { past: [], present: "a", future: [] };
    expect(undoReducer(empty, { type: "undo" })).toBe(empty);
    expect(undoReducer(empty, { type: "redo" })).toBe(empty);
    expect(undoReducer(empty, { type: "set", next: "a" })).toBe(empty);
    
  10. in a label editor, Undo and Redo follow your typing
    function LabelEditor() {
      const { value, set, undo, redo, canUndo, canRedo } = useUndo("");
      return (
        <div>
          <label>
            Tea name <input value={value} onChange={(event) => set(event.target.value)} />
          </label>
          <button onClick={undo} disabled={!canUndo}>Undo</button>
          <button onClick={redo} disabled={!canRedo}>Redo</button>
        </div>
      );
    }
    render(<LabelEditor />);
    const box = screen.getByLabelText("Tea name");
    const undo = screen.getByRole("button", { name: "Undo" });
    const redo = screen.getByRole("button", { name: "Redo" });
    expect(undo).toBeDisabled();
    await user.type(box, "Tea");
    await user.click(undo);
    expect(box).toHaveValue("Te");
    expect(redo).toBeEnabled();
    await user.click(redo);
    expect(box).toHaveValue("Tea");
    expect(redo).toBeDisabled();
    

Learn more

useUndo.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).