Skip to content

Instantly share code, notes, and snippets.

@KristofferEriksson
Created January 31, 2024 11:34
Show Gist options
  • Star 33 You must be signed in to star a gist
  • Fork 9 You must be signed in to fork a gist
  • Save KristofferEriksson/9ce429cbc33a9accb28bb1abdfecc775 to your computer and use it in GitHub Desktop.
Save KristofferEriksson/9ce429cbc33a9accb28bb1abdfecc775 to your computer and use it in GitHub Desktop.
A React hook that enhances your components with powerful undo/redo functionality
import { useCallback, useEffect, useRef, useState } from "react";
interface UseUndoHook<T> {
value: T;
onChange: (newValue: T) => void;
undo: () => void;
redo: () => void;
clear: () => void;
canUndo: boolean;
canRedo: boolean;
}
function useUndo<T extends string | Record<string, unknown>>(
key: string,
initialValue: T,
debounceDelay: number = 400
): UseUndoHook<T> {
const [value, setValue] = useState<T>(initialValue);
const historyRef = useRef<T[]>([initialValue]);
const currentPositionRef = useRef<number>(0);
const [canUndo, setCanUndo] = useState<boolean>(false);
const [canRedo, setCanRedo] = useState<boolean>(false);
const debounceTimerRef = useRef<number | null>(null);
// Update canUndo and canRedo
useEffect(() => {
setCanUndo(currentPositionRef.current > 0);
setCanRedo(currentPositionRef.current < historyRef.current.length - 1);
}, [value]); // Depend on value to trigger updates after undo/redo
// Load initial value from localStorage
useEffect(() => {
const savedValue = localStorage.getItem(key);
if (savedValue) {
try {
const parsedValue = JSON.parse(savedValue) as T;
setValue(parsedValue);
historyRef.current = [parsedValue];
currentPositionRef.current = 0;
} catch (error) {
console.error("Error parsing value from localStorage", error);
}
}
}, [key]);
const updateHistory = useCallback((newValue: T) => {
const newHistory = [
...historyRef.current.slice(0, currentPositionRef.current + 1),
newValue,
];
historyRef.current = newHistory;
currentPositionRef.current = newHistory.length - 1;
}, []);
const onChange = useCallback(
(newValue: T) => {
setValue(newValue);
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
if (typeof newValue === "string" && newValue.trim()) {
updateHistory(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
}
}, debounceDelay) as unknown as number;
},
[debounceDelay, updateHistory]
);
const undo = useCallback(() => {
const newPosition = Math.max(currentPositionRef.current - 1, 0);
if (newPosition !== currentPositionRef.current) {
currentPositionRef.current = newPosition;
const newValue = historyRef.current[newPosition];
if (newValue !== undefined) {
setValue(newValue);
}
localStorage.setItem(key, JSON.stringify(newValue));
}
}, []);
const redo = useCallback(() => {
const newPosition = Math.min(
currentPositionRef.current + 1,
historyRef.current.length - 1
);
if (newPosition !== currentPositionRef.current) {
currentPositionRef.current = newPosition;
const newValue = historyRef.current[newPosition];
if (newValue !== undefined) {
setValue(newValue);
}
localStorage.setItem(key, JSON.stringify(newValue));
}
}, []);
const clear = useCallback(() => {
setValue(initialValue);
historyRef.current = [initialValue];
currentPositionRef.current = 0;
localStorage.removeItem(key);
}, [initialValue, key]);
return { value, onChange, undo, redo, clear, canUndo, canRedo };
}
export default useUndo;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment