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 (
<UndoContext.Provider value={{ record, undo, redo, current }}>
{children}
</UndoContext.Provider>
);
}
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 <input value={value} onChange={handleChange} placeholder="Type something..." />;
}
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 (
<div>
<button onClick={undo}>Undo</button>
<button onClick={redo}>Redo</button>
</div>
);
}
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 (
<UndoProvider>
<EditableInput />
<UndoRedoControls />
</UndoProvider>
);
}
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
🚀 Alternatives
- Zustand with middleware for undo/redo
- use-undo npm package (small and focused)
Summary
Undo/redo isn't hard — it's just careful state tracking. With this context-based setup, you can add reliable undo features to your React apps without reaching for heavy global state managers. Great for creative tools, live editors, and productivity apps.
For a much more extensive guide on getting the most out of React portals, check out my full 24-page PDF file on Gumroad. It's available for just $10:
Using React Portals Like a Pro.
If you found this helpful, you can support me here: buymeacoffee.com/hexshift
Top comments (0)