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.
const { result } = renderHook(() => useUndo("Sencha"));
expect(result.current.value).toBe("Sencha");
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(false);
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");
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");
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");
const { result } = renderHook(() => useUndo("Sencha"));
act(() => result.current.set("Sencha"));
expect(result.current.canUndo).toBe(false);
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);
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);
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"] });
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);
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();
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).
I'm here to help with useUndo. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.