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
State Initialization: The
useHistoryhook takes two parameters:formData(the current form data) andsetFormData(a function to update the form data). Initially, the hook maintains thehistoryarray containing the initial state (formData) and ahistoryIndexto track the current position in the history.Loading from
localStorage: When the component first mounts, theuseEffecthook checks if there is any saved history and index inlocalStorage. If found, it restores both the history and the form data at the correct history index.Updating State and History: The
setStatefunction updates the form data and adds the new state to the history, resetting the redo history if any new changes are made.Undo/Redo: The
undofunction allows the user to go back to a previous state in the history, and theredofunction lets the user move forward to a more recent state.Resetting All States: The
undoAllfunction resets the history index to the first state, whileredoAllsets it to the last state in the history.Nested State Updates: The
updateNestedStatefunction 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.

