An optimistic mutation manager applies a change to your local state the instant the user makes it — before the server has confirmed anything — then quietly commits that change when the server agrees or rolls it back when the server rejects it. The hard part is concurrency: several mutations can be in flight at the same moment, so when one of them fails it must undo only its own change and leave every other pending change untouched.
Implement optimisticMutationManager(initialState), which returns { getState, mutate }. Internally you keep a base state (the last thing the server confirmed) plus an ordered list of pending patches. getState() returns the base with every pending patch applied in order — each patch is a pure state => state function. mutate(options) applies its optimistic patch immediately (so getState() reflects it at once), calls run() to make the server call, and then commits or rolls back when run() settles.
function optimisticMutationManager<S>(initialState: S): {
getState(): S; // base state with every pending patch applied, in order
mutate(options: {
optimistic: (state: S) => S; // pure patch, applied immediately
run: () => Promise<any>; // the async server call
onSuccess?: (base: S, result: any) => S; // fold the confirmed result into base
rollbackOnError?: boolean; // default true: drop the patch on failure
}): Promise<any>; // settles when run() settles
};
const mgr = optimisticMutationManager({ likes: 0 });
const p = mgr.mutate({
optimistic: (s) => ({ likes: s.likes + 1 }),
run: () => likeOnServer(), // resolves later with the true count
onSuccess: (base, count) => ({ likes: count }),
});
mgr.getState(); // { likes: 1 } — shown at once, before the server answers
await p;
mgr.getState(); // { likes: 50 } — the server's authoritative count, folded in
// Two mutations overlap; one fails. Only its change reverts.
const mgr = optimisticMutationManager({ a: 0, b: 0 });
const pa = mgr.mutate({ optimistic: (s) => ({ ...s, a: 1 }), run: () => fail() });
const pb = mgr.mutate({ optimistic: (s) => ({ ...s, b: 1 }), run: () => ok() });
mgr.getState(); // { a: 1, b: 1 } — both applied optimistically
await pa.catch(() => {});
mgr.getState(); // { a: 0, b: 1 } — only a rolled back; b's change survives
getState() must reflect the optimistic patch the moment mutate() returns, before run() settles.mutate() owns exactly one patch; multiple patches can be pending at once and each settles on its own.onSuccess(base, result) when given, otherwise fold the optimistic patch's effect into the base. Either way the patch leaves the pending list.optimistic and onSuccess return a new state and never mutate the state they receive; getState() is recomputed on every call.run() is any async function, so you drive it with resolved or rejected promises; you never need setTimeout.Unlock the solution & editor
Runnable editor + tests
Solve in the browser with instant Jest feedback.
Detailed solutions
Walkthroughs, edge cases, and complexity notes.
Multi-framework variants
React, Vue, Vanilla, Angular — same question, different stacks.