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

🚀 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