Debug optimistic updates under interview pressure
A React screen using optimistic updates passes simple tests but breaks during repeated interaction. Find the likely root cause, patch it, and describe the longer-term design improvement.
Answer Strategy
The broken pattern in this question is "rollback by inverting the optimistic delta". It works for a single in-flight mutation. The moment two mutations queue or another intent succeeds in between, the inversion is wrong because the state at rollback time already reflects the other intents. The fix is structural: confirmed state is the authoritative snapshot, pending mutations form a queue, and the visible value is a deterministic fold of the two. Rollback drops the failed mutation from the queue; the fold collapses to whatever confirmed currently is.
Locate the boundary by asking "what is the source of truth?". The server is the source of truth for confirmed; the user is the source of truth for pending intent. Visible is a projection of both. Inverting deltas treats visible as the source of truth, which is what produces drift under concurrent intents — there is no consistent "before" state to invert against.
Adjacent traps: clearing the entire queue on the response of one mutation (drains other in-flight mutations prematurely), trusting partial server responses (always replace confirmed wholesale), and using a boolean isPending instead of a queue (cannot represent two simultaneous intents). The regression test forces a sequence of success-then-failure and asserts the visible value matches confirmed at the end, not "previous visible minus this delta".
Regression Fix: Confirmed Snapshot + Mutation Queue + Deterministic Fold
The fixed LikeButton holds confirmed snapshot in state and computes visible as a fold over queued mutations; rollback drops the failed mutation only.
// THE BUG: rollback was implemented as "subtract the optimistic delta"
// rather than "snap back to confirmed truth". When two intents queued in
// parallel and one failed, the rollback subtracted its delta from a state
// that already reflected the second intent — leaving a wrong total.
// The fix replaces confirmed wholesale and treats pending mutations as
// a queue keyed by id.
type Snapshot = { liked: boolean; total: number };
type Mutation = { id: string; intent: 'like' | 'unlike' };
type Server = {
toggleLike: (intent: 'like' | 'unlike') => Promise<Snapshot>;
};
export function LikeButton({
initialSnapshot,
server,
}: {
initialSnapshot: Snapshot;
server: Server;
}) {
const [confirmed, setConfirmed] = React.useState(initialSnapshot);
const [queue, setQueue] = React.useState<Mutation[]>([]);
const visible = queue.reduce<Snapshot>(
(acc, mutation) => ({
liked: mutation.intent === 'like',
total:
mutation.intent === 'like' && !acc.liked
? acc.total + 1
: mutation.intent === 'unlike' && acc.liked
? acc.total - 1
: acc.total,
}),
confirmed
);
async function toggle() {
const mutation: Mutation = {
id: Math.random().toString(36).slice(2),
intent: visible.liked ? 'unlike' : 'like',
};
setQueue((q) => [...q, mutation]);
try {
const snapshot = await server.toggleLike(mutation.intent);
setConfirmed(snapshot);
} catch {
// Drop only this mutation. The visible value collapses back to
// confirmed (which the server already advanced if other intents
// succeeded), not to "previous visible minus this delta".
} finally {
setQueue((q) => q.filter((m) => m.id !== mutation.id));
}
}
return (
<button onClick={toggle} aria-pressed={visible.liked}>
{visible.liked ? 'Unlike' : 'Like'} ({visible.total})
</button>
);
}Testing Strategy
Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
describe('LikeButton regression', () => {
it('rolls back to confirmed truth, not "subtract optimistic delta"', async () => {
const responses = [
{ liked: true, total: 5 },
];
let nextResponse = 0;
const server = {
toggleLike: vi.fn(async () => {
if (nextResponse === 0) {
nextResponse++;
// First click succeeds — confirmed advances to liked=true total=5.
return responses[0];
}
// Second click fails — rollback must NOT subtract from 5; it
// must collapse back to confirmed (5).
throw new Error('network down');
}),
};
render(<LikeButton initialSnapshot={{ liked: false, total: 4 }} server={server} />);
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(screen.getByRole('button')).toHaveTextContent('Unlike (5)'));
fireEvent.click(screen.getByRole('button'));
await waitFor(() =>
// Visible reflects the failed unlike attempt while in flight, then
// collapses back to confirmed (Unlike, 5). The broken version
// subtracted 1 and left the user at 4.
expect(screen.getByRole('button')).toHaveTextContent('Unlike (5)')
);
});
});Interviewer Signal
Tests whether you debug from ownership and lifecycle instead of random dependency-array edits.
Constraints
- State a hypothesis before changing code.
- Name what evidence would confirm the bug.
- Avoid broad rewrites unless the current API cannot express the behavior.
Model Answer Shape
- Reproduce the failing sequence first.
- Inspect ownership boundaries: local state, props, effects, subscriptions, and server data.
- Patch the minimal broken boundary and add a regression test.
Tradeoffs
- A minimal patch reduces risk, but repeated lifecycle bugs often justify a small reducer or custom hook.
- Adding dependencies can silence lint warnings while still preserving the wrong ownership model.
Edge Cases
- Double clicks and repeated submissions.
- Slow network responses arriving out of order.
- Component remount with stale persisted state.
Testing And Proof
- Failing interaction sequence.
- Out-of-order async response.
- Unmount cleanup.
Follow-Ups
- What would the code review comment say?
- What metric or log would show this in production?