Skip to main content

Advanced: Optimistic Updates with Conflict Resolution

When multiple users edit the same resource simultaneously or a user's local changes conflict with server updates, naive optimistic patterns break. This article covers advanced techniques: undo/redo stacks, conflict resolution strategies, detecting concurrent mutations, and merging local and remote changes intelligently. These patterns power collaborative apps like Google Docs and Figma.

Implementing Undo/Redo with Mutation History

Track every mutation in a stack. On undo, apply the inverse operation; on redo, reapply.

function useUndoableState(initialValue) {
const [current, setCurrent] = React.useState(initialValue);
const [history, setHistory] = React.useState([initialValue]);
const [historyIndex, setHistoryIndex] = React.useState(0);

const setState = (newValue) => {
const valueToSet = typeof newValue === 'function' ? newValue(current) : newValue;
setCurrent(valueToSet);

// Add to history, discarding any "future" states (after undo)
const newHistory = history.slice(0, historyIndex + 1);
newHistory.push(valueToSet);
setHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
};

const undo = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setCurrent(history[newIndex]);
setHistoryIndex(newIndex);
}
};

const redo = () => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
setCurrent(history[newIndex]);
setHistoryIndex(newIndex);
}
};

return {
current,
setState,
undo,
redo,
canUndo: historyIndex > 0,
canRedo: historyIndex < history.length - 1,
};
}

// Usage in a collaborative editor
export default function CollaborativeEditor() {
const { current: content, setState, undo, redo, canUndo, canRedo } = useUndoableState('');

const saveMutation = useMutation({
mutationFn: async (newContent) => {
const res = await fetch('/api/document', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newContent }),
});
if (!res.ok) throw new Error('Save failed');
return res.json();
},
});

const handleChange = (newContent) => {
setState(newContent);
saveMutation.mutate(newContent); // Debounced in real apps
};

return (
<div>
<div style={{ marginBottom: '1rem' }}>
<button onClick={undo} disabled={!canUndo}>
Undo
</button>
<button onClick={redo} disabled={!canRedo}>
Redo
</button>
</div>

<textarea
value={content}
onChange={(e) => handleChange(e.target.value)}
style={{ width: '100%', height: '300px' }}
/>

{saveMutation.isPending && <p>Saving...</p>}
{saveMutation.isSuccess && <p>Saved!</p>}
</div>
);
}

Detecting and Resolving Conflicts

When the server's version differs from the local cache, merge changes intelligently.

function mergeChanges(local, remote, base) {
// Simple 3-way merge: identify what changed on each side
const localChanges = diffStrings(base, local);
const remoteChanges = diffStrings(base, remote);

// If both changed the same line, there's a conflict
const conflicts = findConflicts(localChanges, remoteChanges);

if (conflicts.length > 0) {
return {
hasConflicts: true,
conflicts,
merged: local, // Default to local; let user resolve
};
}

// No conflicts; merge by applying both changes to base
let merged = base;
localChanges.forEach(([start, end, text]) => {
merged = applyChange(merged, start, end, text);
});
remoteChanges.forEach(([start, end, text]) => {
merged = applyChange(merged, start, end, text);
});

return { hasConflicts: false, merged };
}

// Simplified diff
function diffStrings(a, b) {
// Returns array of [startIdx, endIdx, newText] changes
// In production, use a real diff library like diff-match-patch
return [];
}

function findConflicts(localChanges, remoteChanges) {
// Return overlapping changes
return localChanges.filter(([s1, e1]) =>
remoteChanges.some(([s2, e2]) => !(e1 < s2 || e2 < s1))
);
}

function applyChange(text, start, end, newText) {
return text.slice(0, start) + newText + text.slice(end);
}

// Usage in a mutation
const updateDocMutation = useMutation({
mutationFn: async (newContent) => {
const res = await fetch('/api/document', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newContent }),
});
if (!res.ok) throw new Error('Update failed');
return res.json();
},
onSuccess: (serverData, localData, context) => {
// Check if server version differs from our local
if (serverData.content !== localData) {
const base = context.previousDocument.content;
const merge = mergeChanges(localData, serverData.content, base);

if (merge.hasConflicts) {
// Show conflict UI; let user choose which version to keep
console.log('Merge conflicts detected:', merge.conflicts);
// Prompt user or auto-resolve (e.g., prefer local or remote)
} else {
// Auto-merge succeeded
updateLocalCache(merge.merged);
}
}
},
});

Operational Transformation (OT) for Collaborative Editing

For real-time collaboration, use Operational Transformation to apply concurrent changes in consistent order.

// Simplified OT: Insert and Delete operations
class Operation {
constructor(type, position, content) {
this.type = type; // 'insert' or 'delete'
this.position = position;
this.content = content;
}

apply(text) {
if (this.type === 'insert') {
return text.slice(0, this.position) + this.content + text.slice(this.position);
} else if (this.type === 'delete') {
return text.slice(0, this.position) + text.slice(this.position + this.content.length);
}
return text;
}

// Transform this operation against another to resolve conflicts
transform(other) {
if (this.type === 'insert' && other.type === 'insert') {
if (this.position < other.position) return this;
if (this.position > other.position) {
return new Operation('insert', this.position + other.content.length, this.content);
}
// If same position, preserve order (e.g., client-side always wins)
return new Operation('insert', this.position + other.content.length, this.content);
}

// Implement other combinations (insert vs delete, etc.)
return this;
}
}

// Apply local operation and transform pending ones against incoming remote operation
export default function CollabEditor() {
const [text, setText] = React.useState('');
const [pendingOps, setPendingOps] = React.useState([]);

const applyLocalOp = (op) => {
const newText = op.apply(text);
setText(newText);

// Queue for server
setPendingOps((ops) => [...ops, op]);

// Server sends back ack and any concurrent ops from other clients
// Transform pending ops against incoming ops
};

const handleRemoteOps = (incomingOps) => {
let currentText = text;
let transformedPending = [...pendingOps];

// Apply remote ops first
incomingOps.forEach((remoteOp) => {
currentText = remoteOp.apply(currentText);

// Transform pending ops against this remote op
transformedPending = transformedPending.map((op) => op.transform(remoteOp));
});

setText(currentText);
setPendingOps(transformedPending);
};

return (
<textarea
value={text}
onChange={(e) => applyLocalOp(new Operation('insert', e.target.selectionStart, e.nativeEvent.data))}
/>
);
}

(Real OT is complex; in production, use a library like Yjs or Automerge.)

Detecting Concurrent Mutations with Version Fields

Use version/revision fields to detect when concurrent mutations have occurred.

const updateWithVersionCheck = useMutation({
mutationFn: async ({ id, data, version }) => {
const res = await fetch(`/api/items/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'If-Match': version, // Conditional request (ETag-like)
},
body: JSON.stringify(data),
});

if (res.status === 409) {
// Conflict: server version is newer
throw new Error('Conflict: someone else edited this while you were editing.');
}

if (!res.ok) throw new Error('Update failed');
return res.json(); // Server returns new version
},
onError: (error) => {
if (error.message.includes('Conflict')) {
// Prompt user to refresh and merge
alert(error.message);
window.location.reload(); // Naive; better: fetch server version and show merge dialog
}
},
});

// Usage
const handleSave = (id, data, version) => {
updateWithVersionCheck.mutate({ id, data, version });
};

Implementing a Mutation Queue with Deduplication

Prevent duplicate mutations by tracking in-flight operations and reusing their promises.

function useDedupedMutation(mutationFn) {
const inFlightRef = React.useRef(new Map());

const mutate = React.useCallback(
async (variables) => {
// Create a key for this mutation (stringify variables)
const key = JSON.stringify(variables);

// If this mutation is already in-flight, return the same promise
if (inFlightRef.current.has(key)) {
return inFlightRef.current.get(key);
}

// Fire the mutation
const promise = (async () => {
try {
const result = await mutationFn(variables);
return result;
} finally {
// Clean up after the request completes
inFlightRef.current.delete(key);
}
})();

inFlightRef.current.set(key, promise);
return promise;
},
[mutationFn]
);

return mutate;
}

// Usage
export default function DedupForm() {
const mutate = useDedupedMutation(async (data) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Create failed');
return res.json();
});

const handleSubmit = async () => {
// If clicked twice rapidly, both calls share the same promise
const result1 = await mutate({ email: '[email protected]' });
const result2 = await mutate({ email: '[email protected]' });
// result1 === result2 (same object)
};

return <button onClick={handleSubmit}>Create User</button>;
}

Key Takeaways

  • Implement undo/redo with a state history stack; discard "future" states after an undo + new action.
  • For concurrent mutations, use 3-way merge to detect conflicts and apply non-conflicting changes from both sides.
  • Operational Transformation (OT) is the gold standard for real-time collaboration; use libraries like Yjs for production.
  • Version/revision fields (ETags) let you detect when concurrent edits have occurred; respond with a merge UI.
  • Deduplicate mutations by tracking in-flight promises; reuse them if the same operation is triggered multiple times.
  • Expect conflicts in collaborative apps; design UIs to show conflicts and let users choose which version to keep.

Frequently Asked Questions

Is Operational Transformation the only solution for real-time collaboration?

No. Conflict-free Replicated Data Types (CRDTs) are a newer alternative; they guarantee eventual consistency without a central server. Libraries like Yjs and Automerge use CRDTs.

How do I handle conflicts gracefully for non-technical users?

Show a side-by-side diff of local and server versions and let the user choose which to keep. For simple fields, offer "Keep Local", "Keep Server", or "Merge" options.

What if undo/redo history gets very large?

Limit history to the last N states (e.g., 50) and discard older ones. Or compress history by merging consecutive similar operations.

How do I test concurrent mutation scenarios?

Mock the server to delay responses and simulate concurrent requests. Use Jest fake timers to control the order of operations and verify the merge logic.

Should I implement OT myself?

No. Use a battle-tested library (Yjs, Automerge, or similar). Implementing OT correctly is notoriously complex and error-prone.

Further Reading