Community JavaScript Snippet
useUndoRedo Hook With Bounded History
The custom hook I drop in whenever an editor screen needs cmd+z. Gives you `undo`, `redo`, `set`, and a hard cap on memory so a long session does not balloon the heap.
useUndoRedo Hook With Bounded History
The custom hook I drop in whenever an editor screen needs cmd+z. Gives you `undo`, `redo`, `set`, and a hard cap on memory so a long session does not balloon the heap.
By @sanjayward
November 21, 2025
·
Updated May 20, 2026
622 views
12
4.2 (12)
I always extract the reducer out of the hook before I write useState. The shape { past, present, future, limit } is the standard one (Redux undo uses it too) and the three operations only touch arrays, so I can test them in vitest with no DOM at all. The Object.is short-circuit in pushHistory is non-optional: without it, every keystroke that produces the same value still pushes a history entry and a 30-minute typing session blows past 10k frames. The limit slice keeps the past array bounded, which is the difference between a hook that ships and one that crashes on long-running editors.
The hook is genuinely thin once the helpers exist: one useState and three useCallbacks. I wrap the public object in useMemo so consumers can pass it to useEffect without thrashing dependencies, and I default limit to 50 because most editor sessions stay below that. The standalone fallback at the top is so the snippet runs in this playground without a React runtime; in a real app you would just import { useState, useCallback, useMemo } from 'react'. The empty [] deps on the callbacks are safe because setState accepts a functional updater and never closes over a stale state.
Wiring the hook to keyboard shortcuts is the part most React tutorials skip, so here is the version I actually ship. cmd/ctrl + z undoes, cmd/ctrl + shift + z and ctrl + y redo, and I always call e.preventDefault() so the browser's own undo on contenteditable does not fight the hook. The useEffect deps are the two stable callbacks from the hook, which is why useCallback mattered earlier: without it, the effect would tear down and re-bind on every keystroke. In a real MarkdownEditor component, the only extra line is a <textarea value={editor.value} onChange={(e) => editor.set(e.target.value)} />.
