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.

JavaScript
Frontend
3 snippets
react
hooks
undo-redo
utility
sanjayward

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.