Skip to content

Undo/redo without rebuilding your state layer

A form with four fields. Ctrl+Z walks back through every edit, Ctrl+Shift+Z walks forward. A fresh edit forgets the redo trail. Cap the history so it can’t grow forever.

Users expect undo. In React, adding it means restructuring how your component holds state — before you write a line of UI.


To get undo you first have to make your state undoable: bundle every tracked field into one object, route every edit through a reducer, and hand-write the past/present/future transitions.

import { useReducer } from "react"
type Form = { name: string; email: string; phone: string; company: string }
type History = { past: Form[]; present: Form; future: Form[] }
type Action = { type: "edit"; patch: Partial<Form> } | { type: "undo" } | { type: "redo" }
const empty: Form = { name: "", email: "", phone: "", company: "" }
const MAX = 50
function reducer(h: History, a: Action): History {
switch (a.type) {
case "edit": {
const present = { ...h.present, ...a.patch }
return { past: [...h.past, h.present].slice(-MAX), present, future: [] } // ← reset redo
}
case "undo": {
if (h.past.length === 0) return h
return { past: h.past.slice(0, -1), present: h.past.at(-1)!, future: [h.present, ...h.future] }
}
case "redo": {
if (h.future.length === 0) return h
return { past: [...h.past, h.present].slice(-MAX), present: h.future[0], future: h.future.slice(1) }
}
}
}
export function CustomerForm() {
const [h, dispatch] = useReducer(reducer, { past: [], present: empty, future: [] })
const { name, email } = h.present
const canUndo = h.past.length > 0
const canRedo = h.future.length > 0
// every field: onChange={(v) => dispatch({ type: "edit", patch: { name: v } })}
// ... view
}

~35 lines of history machinery before the form exists — and your component’s state is now a reducer, not four plain fields.

export component CustomerFormPage uses undoable {
state name: string = ""
state email: string = ""
state phone: string = ""
state company: string = ""
action updateName(v: string) { name = v }
action updateEmail(v: string) { email = v }
action updatePhone(v: string) { phone = v }
action updateCompany(v: string) { company = v }
view {
// undo, redo, canUndo, canRedo, historySize, clearHistory are in scope
<button onClick={undo} disabled={!canUndo}>↶ Undo</button>
<button onClick={redo} disabled={!canRedo}>↷ Redo</button>
<Field value={name} onChange={updateName} />
{/* …the other three fields… */}
}
}
Compiled React 19 — this is the file in your repo
import { useCallback, useEffect, useState } from "react"
import { useUndoHistory } from "@reactra/behaviours/undoable"
export const CustomerFormPage = () => {
const [name, setName] = useState(("") as string)
const [email, setEmail] = useState(("") as string)
const [phone, setPhone] = useState(("") as string)
const [company, setCompany] = useState(("") as string)
const __undo = useUndoHistory({ maxHistory: 50 })
const __snapshot = () => ({ name, email, phone, company })
const __restore = (s) => { setName(s.name); setEmail(s.email); setPhone(s.phone); setCompany(s.company) }
const undo = useCallback(() => { const s = __undo.undo(__snapshot()); if (s) __restore(s) }, [name, email, phone, company])
const redo = useCallback(() => { const s = __undo.redo(__snapshot()); if (s) __restore(s) }, [name, email, phone, company])
const canUndo = __undo.canUndo
const canRedo = __undo.canRedo
const historySize = __undo.size
const clearHistory = useCallback(() => __undo.clear(), [])
const updateName = useCallback((v: string) => { __undo.push(__snapshot()); setName(v) }, [name, email, phone, company])
const updateEmail = useCallback((v: string) => { __undo.push(__snapshot()); setEmail(v) }, [name, email, phone, company])
const updatePhone = useCallback((v: string) => { __undo.push(__snapshot()); setPhone(v) }, [name, email, phone, company])
const updateCompany = useCallback((v: string) => { __undo.push(__snapshot()); setCompany(v) }, [name, email, phone, company])
useEffect(() => { const h = globalThis.__REACTRA_TEST__; if (h) h.update("CustomerFormPage", { name, email, phone, company, undo, redo, canUndo, canRedo, historySize, clearHistory, updateName, updateEmail, updatePhone, updateCompany }) })
return (<>
// undo, redo, canUndo, canRedo, historySize, clearHistory are in scope
<button onClick={undo} disabled={!canUndo}>↶ Undo</button>
<button onClick={redo} disabled={!canRedo}>↷ Redo</button>
<Field value={name} onChange={updateName} />
{/* …the other three fields… */}
</>)
}
export default CustomerFormPage

State stays four plain fields. Actions stay one-liners. uses undoable adds the history — and undo/redo/canUndo/canRedo/historySize/clearHistory appear as ordinary bindings you wire to buttons.


  • The reducer rewrite — you keep plain state; you don’t bundle fields into one object
  • past / present / future bookkeeping → a snapshot is taken at each action boundary, for you
  • “reset the redo stack on a new edit” (the bug everyone forgets) → built in
  • The history cap → built in (50 by default)
  • Multi-field restore + a re-render storm → React 19 batches the restore into one render

Same undo behaviour. None of the state surgery.


uses undoable is compiler-native. It compiles to a small useUndoHistory hook plus a snapshot taken before each state-writing action — ordinary React 19 (useState, useCallback, no useContext). It’s always-on, in-memory, and never leaves the app — it’s an editing feature, not telemetry.

You write the edits. The compiler writes the part that remembers them.