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.
React — what you actually write
Section titled “React — what you actually write”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.
Reactra — what you describe
Section titled “Reactra — what you describe”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… */} }}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 CustomerFormPageState 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.
What disappeared
Section titled “What disappeared”- The reducer rewrite — you keep plain
state; you don’t bundle fields into one object past/present/futurebookkeeping → 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.
It’s not a new runtime
Section titled “It’s not a new runtime”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.