We deploy world-class Creative
on demand.

Get Update

We deploy world-class Creative
on demand.

Get Update

Frontend DevelopmentReact State ManagementTue Jan 09 2024

How to Implement Undo/Redo Functionality in React Forms with a Custom Hook

Managing form state efficiently in React can sometimes be tricky, especially when you need to handle complex interactions like undo and redo. This is where the concept of a history state becomes really useful. With history management, you can easily navigate through form changes and revert to previous states when needed.

In this post, we’ll walk you through creating a custom React hook that adds undo and redo functionality to your form data. We’ll make it persistent, so users can reload the page and still retain their form state history using localStorage.

Let’s dive into it.


The Problem

Imagine a form where users can modify multiple fields. As the form grows in complexity, keeping track of changes becomes essential. Often, users want the ability to undo or redo changes, similar to the functionality you'd see in a text editor. Without this feature, users are forced to manually revert their changes, which can be frustrating and inefficient.

This is where managing form state with history becomes handy. By storing every change in a history stack, users can easily go back and forth between different states of the form.


The Solution: A Custom useHistory Hook

We’ll implement a custom React hook that will help us manage the state history of a form and allow users to perform undo and redo actions. Here's how we’ll break it down:

  • State Management: We'll maintain two pieces of state: one for the history of form data and another for the current position (or index) in the history stack.

  • Persistence: The state will be persisted in localStorage, so even after the user refreshes the page, they can still access the history.

  • Undo/Redo: Functions will allow users to navigate backward and forward through the history.


The Code

Let’s look at the implementation of the custom useHistory hook.

import { useState, useEffect } from "react";

// Custom hook that manages form state history with undo/redo functionality
const useHistory = (formData, setFormData) => {
  // State to keep track of the history of form data
  const [history, setHistory] = useState([formData]);
  
  // State to track the current index in the history
  const [historyIndex, setHistoryIndex] = useState(0);

  // Effect hook to load the saved history and history index from localStorage on mount
  useEffect(() => {
    const savedHistory = JSON.parse(localStorage.getItem("formHistory"));
    const savedIndex = localStorage.getItem("historyIndex");

    // If saved history and index exist in localStorage, restore them
    if (savedHistory && savedIndex !== null) {
      setHistory(savedHistory);
      setHistoryIndex(Number(savedIndex));
      setFormData(savedHistory[Number(savedIndex)]); // Set form data to the saved state
    }
  }, []);

  // Effect hook to save history and current index to localStorage whenever they change
  useEffect(() => {
    localStorage.setItem("formHistory", JSON.stringify(history)); // Save the history array
    localStorage.setItem("historyIndex", historyIndex); // Save the current index
  }, [history, historyIndex]);

  // Function to update the form state and history when a new state is set
  const setState = (newState) => {
    // Update the history by adding the new state after the current history index
    const updatedHistory = [...history.slice(0, historyIndex + 1), newState];
    setHistory(updatedHistory); // Update the history state
    setHistoryIndex(updatedHistory.length - 1); // Set the new index to the latest state
    setFormData(newState); // Update the form data with the new state
  };

  // Function to undo the last change by decreasing the history index
  const undo = () => {
    if (historyIndex > 0) { // Only allow undo if we're not at the beginning of the history
      const newIndex = historyIndex - 1;
      setHistoryIndex(newIndex); // Move the index back by 1
      setFormData(history[newIndex]); // Set form data to the previous state
    }
  };

  // Function to redo the last undone change by increasing the history index
  const redo = () => {
    if (historyIndex < history.length - 1) { // Only allow redo if we're not at the end of the history
      const newIndex = historyIndex + 1;
      setHistoryIndex(newIndex); // Move the index forward by 1
      setFormData(history[newIndex]); // Set form data to the next state
    }
  };

  // Function to undo all changes, resetting the history to the first state
  const undoAll = () => {
    setHistoryIndex(0); // Reset the index to the start
    setFormData(history[0]); // Set form data to the very first state in history
  };

  // Function to redo all changes, setting the history to the latest state
  const redoAll = () => {
    setHistoryIndex(history.length - 1); // Set the index to the latest state
    setFormData(history[history.length - 1]); // Set form data to the latest state in history
  };

  // Function to update a nested property in the form data using a path
  const updateNestedState = (path, value) => {
    const newState = { ...formData }; // Create a shallow copy of the current state
    let temp = newState; // Start with the copied state
    const keys = path.split("."); // Split the path into keys to navigate through the object

    // Loop through the keys to navigate to the correct nested property
    for (let i = 0; i < keys.length - 1; i++) {
      temp = temp[keys[i]]; // Navigate to the next level
    }
    
    // Update the final key with the new value
    temp[keys[keys.length - 1]] = value;
    setState(newState); // Update the form state with the modified data
  };

  // Return the functions that manage state and history
  return {
    setState, // Function to set a new state and update history
    undo, // Function to undo the last change
    redo, // Function to redo the last undone change
    undoAll, // Function to undo all changes
    redoAll, // Function to redo all changes
    updateNestedState, // Function to update a nested property in the state
  };
};

export default useHistory; // Export the custom hook

How It Works

  1. State Initialization: The useHistory hook takes two parameters: formData (the current form data) and setFormData (a function to update the form data). Initially, the hook maintains the history array containing the initial state (formData) and a historyIndex to track the current position in the history.

  2. Loading from localStorage: When the component first mounts, the useEffect hook checks if there is any saved history and index in localStorage. If found, it restores both the history and the form data at the correct history index.

  3. Updating State and History: The setState function updates the form data and adds the new state to the history, resetting the redo history if any new changes are made.

  4. Undo/Redo: The undo function allows the user to go back to a previous state in the history, and the redo function lets the user move forward to a more recent state.

  5. Resetting All States: The undoAll function resets the history index to the first state, while redoAll sets it to the last state in the history.

  6. Nested State Updates: The updateNestedState function allows for updates to nested properties in the form data. It uses a path string (e.g., "address.city") to target the correct nested property and update it.


Use Case Example

Let’s consider a simple form where a user is entering their name, email, and address.

import React, { useState } from "react";
import useHistory from "./useHistory";

const Form = () => {
  const [formData, setFormData] = useState({ name: "", email: "", address: { city: "" } });
  const {
    setState,
    undo,
    redo,
    undoAll,
    redoAll,
    updateNestedState,
  } = useHistory(formData, setFormData);

  return (
    <div>
      <input
        type="text"
        value={formData.name}
        onChange={(e) => setState({ ...formData, name: e.target.value })}
        placeholder="Name"
      />
      <input
        type="email"
        value={formData.email}
        onChange={(e) => setState({ ...formData, email: e.target.value })}
        placeholder="Email"
      />
      <input
        type="text"
        value={formData.address.city}
        onChange={(e) => updateNestedState("address.city", e.target.value)}
        placeholder="City"
      />
      <button onClick={undo}>Undo</button>
      <button onClick={redo}>Redo</button>
      <button onClick={undoAll}>Undo All</button>
      <button onClick={redoAll}>Redo All</button>
    </div>
  );
};

export default Form;

Conclusion

This custom useHistory hook is a simple yet powerful way to add undo and redo functionality to your React forms. It allows users to navigate between form states effortlessly and even persists their history across page reloads.

By encapsulating the history management logic in a custom hook, we can easily reuse it across multiple forms or other stateful components in the application. Additionally, the hook’s support for nested state updates adds flexibility when dealing with more complex form structures.

With this implementation, users will enjoy a more robust and user-friendly experience, ensuring they never lose their progress while interacting with the form.


Next Steps

  • You can further optimize the hook by adding a limit to the history size (e.g., to avoid storing too many states).

  • Consider adding more advanced features, like custom history actions or integrating with a version control system for forms.


TheWebVale Engineering Team
Verified Authors

We build mission-critical Next.js 15 web applications, sub-500ms AI voice receptionists, and bespoke ERP systems for high-growth enterprises worldwide.