An optimistic UI updates the screen before the server confirms the change. It feels instant, but it owes the user a promise: if the request fails, the UI has to put everything back exactly the way it was. Most React codebases keep that promise with ad-hoc snapshots — a deep clone here, a useRef of the old value there — and the bookkeeping rots as the state shape grows.
There is a cleaner primitive hiding in plain sight: RFC 6902 JSON Patch. A patch is a tiny, standard, reversible description of what changed. If you compute one patch per mutation, you get optimistic apply, failure rollback, and undo/redo from the same object — no bespoke snapshot logic.
What a JSON Patch actually is
RFC 6902 defines a patch as an ordered list of operations against a JSON document, addressed by RFC 6901 JSON Pointer paths:
[
{ "op": "replace", "path": "/user/name", "value": "Ada" },
{ "op": "add", "path": "/user/roles/1", "value": "ops" },
{ "op": "remove", "path": "/cart/items/0" }
]Because the format is standard, the same patch is understood by any IETF-compliant consumer on the server, in a queue, or in another service. And because every operation has an inverse, a patch can be turned around to undo itself — which is the whole trick.
The three jobs, one patch
I use diffcore, a JSON diff engine written in Rust and shipped to npm as WebAssembly, because the diff runs hot on every keystroke and I want it off the JS main thread budget. Any RFC 6902 library works; the pattern is what matters. The API is three functions:
import { diff, applyPatch, revertPatch } from "diffcore";
const before = { user: { name: "Ada", roles: ["admin"] } };
const after = { user: { name: "Ada", roles: ["admin", "ops"] } };
const patch = diff(before, after);
// [ { op: "add", path: "/user/roles/1", value: "ops" } ]
const next = applyPatch(before, patch); // forward: the optimistic state
const back = revertPatch(next, patch); // inverse: exactly the old state- Optimistic apply — compute the next state, render it immediately, fire the request.
- Rollback — if the request rejects,
revertPatchputs the document back, byte for byte. No stale clone to trust. - Undo/redo — push each patch onto a stack. Undo is
revertPatch; redo isapplyPatch. The stack holds tiny diffs, not full document copies.
The React pattern
Wrap the mutation so the optimistic update and its rollback are the same code path:
function useOptimistic(initial) {
const [state, setState] = useState(initial);
const undo = useRef([]); // stack of applied patches
async function mutate(next, persist) {
const patch = diff(state, next);
if (patch.length === 0) return;
setState(next); // optimistic: render now
undo.current.push(patch);
try {
await persist(patch); // send the standard patch to the server
} catch (err) {
setState((s) => revertPatch(s, patch)); // promise kept: roll back
undo.current.pop();
throw err;
}
}
return { state, mutate, undo };
}The server receives a standard JSON Patch, not your private payload shape, so the same endpoint serves your web app, a mobile client, and a background job. For high-frequency editors there is a useDiff hook that memoizes the patch between two state objects so you are not re-diffing on every render.
Why send the patch, not the whole object
Two clients editing the same record will clobber each other if each PUTs a full object — last write wins, silently. A patch carries only the intent (add /user/roles/1), so the server can apply changes that do not touch the same path concurrently, and you can detect the ones that do. It is also smaller on the wire, which matters once you are syncing on every keystroke. This is the same reason state-sync systems lean on diffs instead of snapshots.
When this is worth it
Reach for the patch primitive when you have optimistic mutations, undo/redo, collaborative or multi-tab editing, or a "review changes" screen. For a form that saves once and navigates away, a single snapshot is simpler — don't over-build it. The win shows up exactly when the hand-rolled snapshot bookkeeping starts to hurt.
The diff engine I used is diffcore — Rust compiled to WebAssembly, RFC 6902 in and out, with applyPatch / revertPatch and a useDiff hook. If you are weighing options, I wrote a diffcore vs jsondiffpatch comparison.