All questions

Optimistic Mutation Manager

Premium

Optimistic Mutation Manager

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.

Signature

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
};

Examples

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

Notes

  • ImmediategetState() must reflect the optimistic patch the moment mutate() returns, before run() settles.
  • Per-mutation patches — each mutate() owns exactly one patch; multiple patches can be pending at once and each settles on its own.
  • Isolated rollback — a failed mutation removes only its own patch. Concurrent pending changes must be left exactly as they were.
  • Commit on success — apply onSuccess(base, result) when given, otherwise fold the optimistic patch's effect into the base. Either way the patch leaves the pending list.
  • Pure patchesoptimistic and onSuccess return a new state and never mutate the state they receive; getState() is recomputed on every call.
  • No timers — this is pure promise plumbing. run() is any async function, so you drive it with resolved or rejected promises; you never need setTimeout.

FAQ

Why keep a list of patches instead of just overwriting the state?
Because several mutations can be in flight at once. A single overwritten value cannot tell which change belongs to which request, so rolling one back would clobber the others. A list of per-mutation patches lets each one settle independently.
Why remove a patch by object identity instead of by index?
Positions shift when a sibling mutation settles and splices out its own patch. Removing by the entry's own reference always targets exactly the right patch, so a concurrent rollback never hits the wrong one.
What happens to the optimistic change when the server call succeeds?
The patch is removed from the pending list and its effect is folded into the base state, either by applying onSuccess(base, result) with the server's authoritative value, or by re-applying the optimistic patch to the base when no onSuccess is given.

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.

Upgrade to Premium