How to Build Undo and Redo with useReducer

Build undo and redo in React with useReducer by storing past, present, and future state in arrays, with reducer cases and disabled button states.

5 min read

Undo and redo with useReducer means keeping three values: the present state plus two history arrays. Every new action pushes the old present into the past and clears the future, while undo and redo move between the stacks.

Because reducers are pure, the whole history is just data you can inspect and test. The result feels like a text editor's undo stack, but it works for any state.

Shape the history

The state has a past array, a present value, and a future array. A set action records the current present before replacing it, so nothing is lost. The three fields always satisfy one invariant: present is on screen, past holds the steps before it, and future holds the steps after it.

index.jsindex.js
const initial = { past: [], present: 0, future: [] };
function reducer(state, action) {
  switch (action.type) {
    case "set":
      return { past: [...state.past, state.present], present: action.value, future: [] };
    default:
      return state;
  }
}

The set case appends the old present to past and empties future. Emptying future is what makes redo unavailable after a new change, because the user branched away from the path they could replay.

Move through history with undo and redo

Undo pops the last value from past into present and pushes the old present onto future. Redo does the reverse, so the two directions reuse the same array moves, just mirrored.

index.jsindex.js
case "undo":
  return {
    past: state.past.slice(0, -1),
    present: state.past[state.past.length - 1],
    future: [state.present, ...state.future]
  };
case "redo":
  return {
    past: [...state.past, state.present],
    present: state.future[0],
    future: state.future.slice(1)
  };

Undo reads the last item of past as the new present. Redo reads the first item of future. Both keep the untouched side of history intact.

When past is empty, undo has nothing to pop, and when future is empty, redo has nothing to replay.

Cap the history so it stays small

Unbounded history grows with every change. In the set case, keep only the most recent steps by slicing the past array, such as state.past.slice(-50). Older steps fall off, which bounds memory while keeping undo and redo fast.

Redo already stays bounded because any new set clears the future. Bounding the past this way keeps the cost of each action constant no matter how long the user edits.

Wire the buttons

The component renders the present value and two buttons that dispatch undo and redo. Disable each button when its history is empty, and keep them as real buttons so keyboard users can trigger them.

App.jsxApp.jsx
function Counter() {
  const [state, dispatch] = useReducer(reducer, initial);
  return (
    <div>
      <button disabled={state.past.length === 0} onClick={() => dispatch({ type: "undo" })}>Undo</button>
      <span>{state.present}</span>
      <button disabled={state.future.length === 0} onClick={() => dispatch({ type: "redo" })}>Redo</button>
    </div>
  );
}

With no past steps, Undo is disabled. After one undo, Redo becomes available because future now holds the reverted value. As the user clicks, the number walks backward and forward while the disabled state of each button updates.

See how to write actions and reducers for the naming rules behind these cases, or update arrays in React state for the immutable array operations.

Rune AI

Rune AI

Key Insights

  • Keep past, present, and future in one state object.
  • Every set pushes the old present into past and clears future.
  • Undo moves present into future and pops past.
  • Redo does the reverse.
  • Disable buttons when their history is empty.
RunePowered by Rune AI

Frequently Asked Questions

Why keep past and future as arrays?

Undo pops from the past array and pushes the present into future. Redo does the reverse. Arrays make both directions cheap and predictable.

What clears the future history?

Any new set action clears future because the user branched away from the path they could redo.

How do I cap the history length?

Slice the past array to a limit inside the set case, such as state.past.slice(-50), so old steps fall off.

Conclusion

Undo and redo with useReducer means keeping present state plus past and future arrays. Each set pushes the old present into past and clears future, while undo and redo move values between the stacks and update the present.