How to Build a Persistent Undo/Redo Stack in React Without Redux
Undo/redo functionality isn't just for text editors — it's critical for rich apps like form builders, design tools, and config editors. Here's how to build a fully working persistent undo/redo stack in React using only hooks and context — no Redux, no Zustand. Why Build an Undo/Redo Stack? Common use cases: Recover user mistakes easily Improve UX for complex editing flows Enable "draft" save systems with full history Step 1: Create the Undo Context This context will track a history of states and provide undo/redo functions: // undoContext.js import { createContext, useContext, useState } from "react"; const UndoContext = createContext(null); export function UndoProvider({ children }) { const [history, setHistory] = useState([]); const [currentIndex, setCurrentIndex] = useState(-1); const record = (newState) => { const newHistory = history.slice(0, currentIndex + 1); newHistory.push(newState); setHistory(newHistory); setCurrentIndex(newHistory.length - 1); }; const undo = () => { if (currentIndex > 0) setCurrentIndex(currentIndex - 1); }; const redo = () => { if (currentIndex < history.length - 1) setCurrentIndex(currentIndex + 1); }; const current = history[currentIndex] || null; return ( {children} ); } export function useUndo() { return useContext(UndoContext); } Step 2: Build an Editable Component Let's make a simple editable text input that records its history: // EditableInput.js import { useUndo } from "./undoContext"; import { useState, useEffect } from "react"; function EditableInput() { const { record, current } = useUndo(); const [value, setValue] = useState(""); useEffect(() => { if (current !== null) { setValue(current); } }, [current]); const handleChange = (e) => { setValue(e.target.value); record(e.target.value); }; return ; } export default EditableInput; Step 3: Add Undo/Redo Buttons Control the undo/redo from anywhere in your app: // UndoRedoControls.js import { useUndo } from "./undoContext"; function UndoRedoControls() { const { undo, redo } = useUndo(); return ( Undo Redo ); } export default UndoRedoControls; Step 4: Wrap the App with the UndoProvider // App.js import { UndoProvider } from "./undoContext"; import EditableInput from "./EditableInput"; import UndoRedoControls from "./UndoRedoControls"; function App() { return ( ); } export default App; Pros and Cons ✅ Pros Lightweight — no third-party dependencies Fully persistent history stack Easy to expand to more complex states ⚠️ Cons Memory usage grows if history isn't trimmed Best for small/medium states — large states might need diffing No batching of similar actions
Undo/redo functionality isn't just for text editors — it's critical for rich apps like form builders, design tools, and config editors. Here's how to build a fully working persistent undo/redo stack in React using only hooks and context — no Redux, no Zustand.
Why Build an Undo/Redo Stack?
Common use cases:
- Recover user mistakes easily
- Improve UX for complex editing flows
- Enable "draft" save systems with full history
Step 1: Create the Undo Context
This context will track a history of states and provide undo/redo functions:
// undoContext.js
import { createContext, useContext, useState } from "react";
const UndoContext = createContext(null);
export function UndoProvider({ children }) {
const [history, setHistory] = useState([]);
const [currentIndex, setCurrentIndex] = useState(-1);
const record = (newState) => {
const newHistory = history.slice(0, currentIndex + 1);
newHistory.push(newState);
setHistory(newHistory);
setCurrentIndex(newHistory.length - 1);
};
const undo = () => {
if (currentIndex > 0) setCurrentIndex(currentIndex - 1);
};
const redo = () => {
if (currentIndex < history.length - 1) setCurrentIndex(currentIndex + 1);
};
const current = history[currentIndex] || null;
return (
{children}
);
}
export function useUndo() {
return useContext(UndoContext);
}
Step 2: Build an Editable Component
Let's make a simple editable text input that records its history:
// EditableInput.js
import { useUndo } from "./undoContext";
import { useState, useEffect } from "react";
function EditableInput() {
const { record, current } = useUndo();
const [value, setValue] = useState("");
useEffect(() => {
if (current !== null) {
setValue(current);
}
}, [current]);
const handleChange = (e) => {
setValue(e.target.value);
record(e.target.value);
};
return ;
}
export default EditableInput;
Step 3: Add Undo/Redo Buttons
Control the undo/redo from anywhere in your app:
// UndoRedoControls.js
import { useUndo } from "./undoContext";
function UndoRedoControls() {
const { undo, redo } = useUndo();
return (
);
}
export default UndoRedoControls;
Step 4: Wrap the App with the UndoProvider
// App.js
import { UndoProvider } from "./undoContext";
import EditableInput from "./EditableInput";
import UndoRedoControls from "./UndoRedoControls";
function App() {
return (
);
}
export default App;
Pros and Cons
✅ Pros
- Lightweight — no third-party dependencies
- Fully persistent history stack
- Easy to expand to more complex states
⚠️ Cons
- Memory usage grows if history isn't trimmed
- Best for small/medium states — large states might need diffing
- No batching of similar actions