DEV3 Technologies home

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 reset action 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

  1. Save as hooks/useHistory.ts.
  2. const { state, set, undo, redo, canUndo, canRedo } = useHistory<Shape[]>([], 100).
  3. Call set(next) for committed edits and set(next, true) while dragging.
  4. Bind undo and redo to 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.

Related snippets