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)
// Pure helpers behind useUndoRedo. Keeping them outside the hook means I can
// unit-test them without rendering, and the hook body stays under 30 lines.
function makeHistory(initial, limit) {
return { past: [], present: initial, future: [], limit };
}
function pushHistory(state, action) {
const next = typeof action === 'function' ? action(state.present) : action;
if (Object.is(next, state.present)) return state;
const past = state.past.concat([state.present]);
const trimmed = past.length > state.limit ? past.slice(past.length - state.limit) : past;
return { past: trimmed, present: next, future: [], limit: state.limit };
}
function applyUndo(state) {
if (state.past.length === 0) return state;
const previous = state.past[state.past.length - 1];
const newPast = state.past.slice(0, -1);
return { past: newPast, present: previous, future: [state.present].concat(state.future), limit: state.limit };
}
function applyRedo(state) {
if (state.future.length === 0) return state;
const next = state.future[0];
return { past: state.past.concat([state.present]), present: next, future: state.future.slice(1), limit: state.limit };
}
// Drive the helpers without React so we can see the behavior in stdout.
let s = makeHistory('draft 1', 50);
s = pushHistory(s, 'draft 2');
s = pushHistory(s, 'draft 3');
console.log('after two edits ->', s.present, '| past depth:', s.past.length);
s = applyUndo(s);
console.log('after undo ->', s.present);
s = applyUndo(s);
console.log('after undo ->', s.present);
s = applyRedo(s);
console.log('after redo ->', s.present);
// New edit after undo wipes the redo stack on purpose, just like browser history.
s = pushHistory(s, 'fork');
console.log('after fork edit ->', s.present, '| future:', s.future.length);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.
// useUndoRedo: wraps the pure helpers in a React hook with stable callbacks.
// Standalone fallback below lets the demo print without a real React runtime.
const { useState, useCallback, useMemo } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (next) => { v = typeof next === 'function' ? next(v) : next; return v; }];
},
useCallback: (f) => f,
useMemo: (f) => f(),
});
function makeHistory(initial, limit) { return { past: [], present: initial, future: [], limit }; }
function pushHistory(s, a) {
const next = typeof a === 'function' ? a(s.present) : a;
if (Object.is(next, s.present)) return s;
const past = s.past.concat([s.present]);
const trimmed = past.length > s.limit ? past.slice(past.length - s.limit) : past;
return { past: trimmed, present: next, future: [], limit: s.limit };
}
function applyUndo(s) {
if (s.past.length === 0) return s;
const prev = s.past[s.past.length - 1];
return { past: s.past.slice(0, -1), present: prev, future: [s.present].concat(s.future), limit: s.limit };
}
function applyRedo(s) {
if (s.future.length === 0) return s;
return { past: s.past.concat([s.present]), present: s.future[0], future: s.future.slice(1), limit: s.limit };
}
function useUndoRedo(initial, options) {
const limit = (options && options.limit) || 50;
const [state, setState] = useState(() => makeHistory(initial, limit));
const set = useCallback((action) => setState((s) => pushHistory(s, action)), []);
const undo = useCallback(() => setState(applyUndo), []);
const redo = useCallback(() => setState(applyRedo), []);
const api = useMemo(() => ({
value: state.present,
canUndo: state.past.length > 0,
canRedo: state.future.length > 0,
set, undo, redo,
}), [state, set, undo, redo]);
return api;
}
// Show the public surface in a fake render context.
const api = useUndoRedo('hello', { limit: 100 });
console.log('initial value:', api.value);
console.log('canUndo / canRedo:', api.canUndo, '/', api.canRedo);
console.log('keys:', Object.keys(api).sort().join(', '));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.
// The integration shape I ship in editor screens. Pulls in the hook and the
// helpers; keeps the keyboard handler tiny because the hook owns everything.
const { useState, useCallback, useMemo, useEffect } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useCallback: (f) => f,
useMemo: (f) => f(),
useEffect: () => {},
});
function makeHistory(i, l) { return { past: [], present: i, future: [], limit: l }; }
function pushHistory(s, a) {
const next = typeof a === 'function' ? a(s.present) : a;
if (Object.is(next, s.present)) return s;
const past = s.past.concat([s.present]);
const trimmed = past.length > s.limit ? past.slice(past.length - s.limit) : past;
return { past: trimmed, present: next, future: [], limit: s.limit };
}
function applyUndo(s) {
if (!s.past.length) return s;
return { past: s.past.slice(0, -1), present: s.past[s.past.length - 1],
future: [s.present].concat(s.future), limit: s.limit };
}
function applyRedo(s) {
if (!s.future.length) return s;
return { past: s.past.concat([s.present]), present: s.future[0],
future: s.future.slice(1), limit: s.limit };
}
function useUndoRedo(initial, opts) {
const limit = (opts && opts.limit) || 50;
const [state, setState] = useState(() => makeHistory(initial, limit));
const set = useCallback((a) => setState((s) => pushHistory(s, a)), []);
const undo = useCallback(() => setState(applyUndo), []);
const redo = useCallback(() => setState(applyRedo), []);
return useMemo(() => ({
value: state.present, canUndo: state.past.length > 0, canRedo: state.future.length > 0,
set, undo, redo,
}), [state, set, undo, redo]);
}
// Pretend MarkdownEditor: the exact shape I write in real apps, minus the JSX.
function MarkdownEditor() {
const editor = useUndoRedo('# title\n\n', { limit: 200 });
useEffect(() => {
function onKey(e) {
const meta = e.metaKey || e.ctrlKey;
if (!meta) return;
if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); editor.undo(); }
else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') { e.preventDefault(); editor.redo(); }
}
window && window.addEventListener && window.addEventListener('keydown', onKey);
return () => { window && window.removeEventListener && window.removeEventListener('keydown', onKey); };
}, [editor.undo, editor.redo]);
return editor;
}
// Fake render: simulate a few edits and undo/redo events.
const e = MarkdownEditor();
e.set((v) => v + '## intro\n');
e.set((v) => v + 'first paragraph\n');
console.log('after edits:', JSON.stringify(e.value));
e.undo();
console.log('after undo: canRedo =', e.canRedo);
e.redo();
console.log('after redo: canUndo =', e.canUndo);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)} />.
