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

Apr 26, 2025 - 11:32
 0
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 (
    
); } 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