useOptimisticMutation Hook With Rollback
My take on optimistic UI before React 19's `useOptimistic` was usable. The hook applies the change locally, fires the network call, and rolls back on failure with the original snapshot intact.
By @laylabauer
January 28, 2026
·
Updated May 18, 2026
582 views
8
4.7 (9)
// Optimistic mutation as a state machine. Pure, no React, easy to test.
// Status flow: idle -> pending -> success | error
// On error we restore the snapshot we took at submit time.
function reducer(state, event) {
switch (event.type) {
case 'submit':
return {
status: 'pending',
data: event.optimistic,
snapshot: state.data,
error: null,
};
case 'success':
return { status: 'success', data: event.data, snapshot: null, error: null };
case 'error':
return {
status: 'error',
data: state.snapshot,
snapshot: null,
error: event.error,
};
case 'reset':
return { status: 'idle', data: state.data, snapshot: null, error: null };
default:
return state;
}
}
// Drive a happy path then a failure path.
let state = { status: 'idle', data: { likes: 12 }, snapshot: null, error: null };
state = reducer(state, { type: 'submit', optimistic: { likes: 13 } });
console.log('after submit (optimistic):', state.data, '| status:', state.status);
state = reducer(state, { type: 'success', data: { likes: 13 } });
console.log('after success: ', state.data, '| status:', state.status);
let failing = { status: 'idle', data: { likes: 99 }, snapshot: null, error: null };
failing = reducer(failing, { type: 'submit', optimistic: { likes: 100 } });
console.log('after submit (optimistic):', failing.data, '| status:', failing.status);
failing = reducer(failing, { type: 'error', error: new Error('rate-limited') });
console.log('after error (rolled back):', failing.data, '| error:', failing.error.message);I always start optimistic UI with a state machine because the rollback logic gets ugly fast otherwise. Three fields matter: data (what the UI shows), snapshot (what we restore on error), and status (so the button can disable itself during pending). Capturing the snapshot at submit time, not at error time, is the key invariant: if a second mutation interleaves the response, we still know what to roll back to. A pure reducer like this can be tested with five lines of vitest and shared between hooks if you need an useOptimisticForm and an useOptimisticToggle later.
// useOptimisticMutation wraps the reducer in a hook. The mutate function is
// stable across renders, so consumers can drop it in event handlers safely.
const { useState, useCallback, useRef } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useCallback: (f) => f,
useRef: (init) => ({ current: init }),
});
function reducer(state, ev) {
if (ev.type === 'submit') return { status: 'pending', data: ev.optimistic, snapshot: state.data, error: null };
if (ev.type === 'success') return { status: 'success', data: ev.data, snapshot: null, error: null };
if (ev.type === 'error') return { status: 'error', data: state.snapshot, snapshot: null, error: ev.error };
if (ev.type === 'reset') return { status: 'idle', data: state.data, snapshot: null, error: null };
return state;
}
function useOptimisticMutation(initialData, mutationFn) {
const [state, setState] = useState({ status: 'idle', data: initialData, snapshot: null, error: null });
const fnRef = useRef(mutationFn);
fnRef.current = mutationFn;
const mutate = useCallback(async (input, computeOptimistic) => {
setState((s) => reducer(s, { type: 'submit', optimistic: computeOptimistic(s.data, input) }));
try {
const result = await fnRef.current(input);
setState((s) => reducer(s, { type: 'success', data: result }));
return { ok: true, data: result };
} catch (error) {
setState((s) => reducer(s, { type: 'error', error }));
return { ok: false, error };
}
}, []);
return { ...state, mutate };
}
// Drive the hook with a fake mutationFn so we can see it sequence.
async function fakeApi(input) { return { likes: input.likes }; }
const api = useOptimisticMutation({ likes: 12 }, fakeApi);
console.log('initial state:', api.status, api.data);
console.log('mutate is a function:', typeof api.mutate);
// Real React would re-render after each setState; here we're just showing surface.
(async () => {
const result = await api.mutate({ likes: 13 }, (current) => ({ likes: current.likes + 1 }));
console.log('mutate returned:', result.ok, result.data);
})();Two things in the hook are easy to get wrong, so I want them on the page. First, I stash mutationFn in a ref; if I closed over it directly, the mutate callback would only know about the function passed on first render and stale handlers would dial the wrong API. Second, mutate returns { ok, data } rather than throwing, because optimistic UI almost always wants to show a toast on failure rather than crash a render. I keep the useCallback deps empty on purpose: every state read goes through the functional updater, and every external value goes through the ref.
// The shape I write in real components: one hook call, one event handler.
const { useState, useCallback, useRef } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useCallback: (f) => f,
useRef: (init) => ({ current: init }),
});
function reducer(state, ev) {
if (ev.type === 'submit') return { status: 'pending', data: ev.optimistic, snapshot: state.data, error: null };
if (ev.type === 'success') return { status: 'success', data: ev.data, snapshot: null, error: null };
if (ev.type === 'error') return { status: 'error', data: state.snapshot, snapshot: null, error: ev.error };
return state;
}
function useOptimisticMutation(initialData, mutationFn) {
const [state, setState] = useState({ status: 'idle', data: initialData, snapshot: null, error: null });
const fnRef = useRef(mutationFn); fnRef.current = mutationFn;
const mutate = useCallback(async (input, optim) => {
setState((s) => reducer(s, { type: 'submit', optimistic: optim(s.data, input) }));
try {
const data = await fnRef.current(input);
setState((s) => reducer(s, { type: 'success', data }));
return { ok: true, data };
} catch (error) {
setState((s) => reducer(s, { type: 'error', error }));
return { ok: false, error };
}
}, []);
return { ...state, mutate };
}
// Simulated server that rejects 1 in 2 calls so we exercise both paths.
let attempts = 0;
async function likePost(postId) {
attempts++;
if (attempts % 2 === 0) throw new Error('rate-limited');
return { id: postId, likes: 100 + attempts };
}
function toast(msg) { console.log('TOAST:', msg); }
async function LikeButton() {
const m = useOptimisticMutation({ id: 'p1', likes: 99 }, likePost);
async function onClick() {
const result = await m.mutate('p1', (cur) => ({ ...cur, likes: cur.likes + 1 }));
if (!result.ok) toast(`undo: ${result.error.message}`);
}
await onClick();
console.log('post-success status:', m.status);
await onClick();
console.log('post-error status would be "error" in a real React render');
}
(async () => { await LikeButton(); })();This is what the hook looks like at the call site: one line to wire it up, one event handler. The onClick await on mutate is critical when you need to chain a toast on failure, because the reducer transition happens before the promise resolves but the rollback message lives in the resolved value. In a real React render, after error fires, data would be the original { likes: 99 } again because the reducer copied it from snapshot. The user sees the optimistic increment, then the number rubber-bands back, and a toast explains why. That UX is the whole reason we built this.
// What happens when the user clicks the button twice in rapid succession?
// Naive optimistic UI shows lost-update bugs. We track a generation token so
// late responses are dropped instead of overwriting newer ones.
const { useState, useCallback, useRef } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useCallback: (f) => f,
useRef: (init) => ({ current: init }),
});
function useOptimisticMutationVersioned(initialData, mutationFn) {
const [state, setState] = useState({
gen: 0, status: 'idle', data: initialData, error: null,
});
const fnRef = useRef(mutationFn); fnRef.current = mutationFn;
const genRef = useRef(0);
const snapshotsRef = useRef(new Map());
const mutate = useCallback(async (input, optim) => {
const myGen = ++genRef.current;
setState((s) => {
snapshotsRef.current.set(myGen, s.data);
return { gen: myGen, status: 'pending', data: optim(s.data, input), error: null };
});
try {
const data = await fnRef.current(input);
// Only commit if no newer mutation has started.
if (genRef.current === myGen) {
setState({ gen: myGen, status: 'success', data, error: null });
} else {
console.log(`drop stale success for gen ${myGen}, latest is ${genRef.current}`);
}
snapshotsRef.current.delete(myGen);
return { ok: true, data };
} catch (error) {
if (genRef.current === myGen) {
const restored = snapshotsRef.current.get(myGen);
setState({ gen: myGen, status: 'error', data: restored, error });
}
snapshotsRef.current.delete(myGen);
return { ok: false, error };
}
}, []);
return { ...state, mutate };
}
let calls = 0;
async function slowApi(input) {
calls++;
const ms = calls === 1 ? 30 : 5; // first call lands LATER than second
await new Promise((r) => setTimeout(r, ms));
return { likes: input };
}
async function demo() {
const m = useOptimisticMutationVersioned({ likes: 0 }, slowApi);
const a = m.mutate(1, (cur, n) => ({ likes: cur.likes + n }));
const b = m.mutate(2, (cur, n) => ({ likes: cur.likes + n }));
const [ra, rb] = await Promise.all([a, b]);
console.log('first call result:', ra);
console.log('second call result:', rb);
}
(async () => { await demo(); })();If the user double-clicks faster than the network round trip, two mutations are in flight. Without a generation token, whichever response arrives last wins, even if it is the older one. The genRef is a monotonic counter that increments at submit; the snapshot table is a Map keyed by generation so the matching rollback is still available even if an older mutation fails after a newer one already succeeded. In production I have hit this exactly twice, both times during demo days where someone hammered a button. Adding the generation guard cost three lines and prevented a real lost-update bug.
