Undo and redo history hook
A reducer-based React hook that adds undo, redo, a history limit and coalesced updates to any piece of state.
React / Next.jsHooksreactundo-redohistoryreducer
Code
import { useCallback, useReducer } from 'react';
type State<T> = { past: T[]; present: T; future: T[]; limit: number };
type Action<T> =
| { type: 'set'; value: T; replace: boolean }
| { type: 'undo' }
| { type: 'redo' };
function reducer<T>(s: State<T>, a: Action<T>): State<T> {
switch (a.type) {
case 'set': {
if (Object.is(a.value, s.present)) return s;
// replace = overwrite the current step (used while a drag is in progress)
if (a.replace) return { ...s, present: a.value };
const past = [...s.past, s.present].slice(-s.limit);
return { ...s, past, present: a.value, future: [] };
}
case 'undo': {
if (!s.past.length) return s;
const previous = s.past[s.past.length - 1]!;
return { ...s, past: s.past.slice(0, -1), present: previous, future: [s.present, ...s.future] };
}
case 'redo': {
if (!s.future.length) return s;
const [next, ...rest] = s.future;
return { ...s, past: [...s.past, s.present], present: next!, future: rest };
}
}
}
export function useHistory<T>(initial: T, limit = 100) {
const [s, dispatch] = useReducer(reducer<T>, { past: [], present: initial, future: [], limit });
const set = useCallback((value: T, replace = false) => dispatch({ type: 'set', value, replace }), []);
const undo = useCallback(() => dispatch({ type: 'undo' }), []);
const redo = useCallback(() => dispatch({ type: 'redo' }), []);
return { state: s.present, set, undo, redo, canUndo: s.past.length > 0, canRedo: s.future.length > 0 };
}
How it works
What it solves
Editors such as whiteboards, form builders and text tools feel broken without undo. Implementing it per feature leads to scattered bugs; a single hook that wraps your state makes every change reversible.
How it works
The state is three pieces: past, present and future. A reducer handles four actions. set pushes the old present onto past, replaces it and clears future, because a new edit invalidates the redo branch. undo moves the last past entry to the present and pushes the old present onto future; redo does the reverse.
Dragging a shape produces dozens of updates per second, and each one should not become its own undo step. The set action accepts a replace flag: when true the present is overwritten without touching past, so you can stream intermediate positions with replace and commit a final value normally when the gesture ends. A limit trims the oldest entries so memory stays bounded.
Callbacks are memoised and the booleans canUndo and canRedo are derived, ready to bind to button disabled states.
Adapting it
- Wire Ctrl or Cmd plus Z and Shift plus Z in a keydown listener.
- Store diffs instead of snapshots if your state is large.
- Add a
resetaction that clears both stacks after saving.
Pitfalls
- Snapshots are stored by reference; always produce new objects instead of mutating.
- Do not put UI-only state such as hover or selection in history unless you want it undoable.
- Provide a generous limit for text editing, a small one for big documents.
How to use it
- Save as
hooks/useHistory.ts. const { state, set, undo, redo, canUndo, canRedo } = useHistory<Shape[]>([], 100).- Call
set(next)for committed edits andset(next, true)while dragging. - Bind
undoandredoto buttons or keyboard shortcuts.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.