Implement optimistic updates in a product component
Use optimistic updates to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.
Answer Strategy
Optimistic updates are the question that catches engineers who treat client state as authoritative. The rule: render the next intent immediately, but never let that intent overwrite confirmed truth. The visible value is a fold of (confirmed snapshot) + (queued mutations); on success the snapshot advances, on failure the mutation is dropped and the fold collapses to the last server-confirmed value.
Separate three things: confirmed state (what the server last told us), pending mutations (a queue, not a flag), and visible state (deterministic projection from those two). A boolean isPending is not enough because a user can click twice before the first response — without a queue, the second optimistic update overwrites the first and rollback becomes ambiguous.
Adjacent traps: rolling back by inverting the optimistic delta (wrong if other intents queued in between), trusting the server response shape implicitly (always replace confirmed wholesale, never patch fields), and clearing the queue on the response of a different mutation (drain only the mutation whose id matches). The reference button stays correct under double-click and intermittent network because the queue is identity-keyed.
Reference Implementation: Like Button With Mutation Queue
A LikeButton whose visible state composes confirmed snapshot + queued mutations; failures drop the mutation and re-collapse to confirmed truth.
// Optimistic like-button: render the next state immediately, queue the
// mutation, roll back to the server-confirmed value on failure. The senior
// detail is that "rollback" is not "undo the optimistic delta" — it is
// "snap to whatever the server actually says now".
type Mutation = { id: string; intent: 'like' | 'unlike' };
type Server = {
toggleLike: (intent: 'like' | 'unlike') => Promise<{ liked: boolean; total: number }>;
};
type LikeButtonProps = {
initialLiked: boolean;
initialTotal: number;
server: Server;
};
export function LikeButton({ initialLiked, initialTotal, server }: LikeButtonProps) {
const [confirmed, setConfirmed] = React.useState({
liked: initialLiked,
total: initialTotal,
});
const [pending, setPending] = React.useState<Mutation[]>([]);
// The visible state composes confirmed truth with the queued intents.
// This keeps the optimistic delta deterministic instead of drifting if
// multiple toggles queue up before the server responds.
const visible = pending.reduce(
(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 next: Mutation = {
id: Math.random().toString(36).slice(2),
intent: visible.liked ? 'unlike' : 'like',
};
setPending((q) => [...q, next]);
try {
const result = await server.toggleLike(next.intent);
setConfirmed(result);
} catch {
// Server is the source of truth on failure; do not subtract the
// optimistic delta — the server may have applied other intents in
// between. Just drop our queued attempt.
} finally {
setPending((q) => q.filter((m) => m.id !== next.id));
}
}
return (
<button onClick={toggle} aria-pressed={visible.liked}>
{visible.liked ? 'Unlike' : 'Like'} ({visible.total})
</button>
);
}Runnable Playground
Edit the implementation and run the tests directly in the browser. For system design questions, the playground focuses on the core state/data logic that the UI would call.
// Optimistic like-button: render the next state immediately, queue the
// mutation, roll back to the server-confirmed value on failure. The senior
// detail is that "rollback" is not "undo the optimistic delta" — it is
// "snap to whatever the server actually says now".
type Mutation = { id: string; intent: 'like' | 'unlike' };
type Server = {
toggleLike: (intent: 'like' | 'unlike') => Promise<{ liked: boolean; total: number }>;
};
type LikeButtonProps = {
initialLiked: boolean;
initialTotal: number;
server: Server;
};
export function LikeButton({ initialLiked, initialTotal, server }: LikeButtonProps) {
const [confirmed, setConfirmed] = React.useState({
liked: initialLiked,
total: initialTotal,
});
const [pending, setPending] = React.useState<Mutation[]>([]);
// The visible state composes confirmed truth with the queued intents.
// This keeps the optimistic delta deterministic instead of drifting if
// multiple toggles queue up before the server responds.
const visible = pending.reduce(
(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 next: Mutation = {
id: Math.random().toString(36).slice(2),
intent: visible.liked ? 'unlike' : 'like',
};
setPending((q) => [...q, next]);
try {
const result = await server.toggleLike(next.intent);
setConfirmed(result);
} catch {
// Server is the source of truth on failure; do not subtract the
// optimistic delta — the server may have applied other intents in
// between. Just drop our queued attempt.
} finally {
setPending((q) => q.filter((m) => m.id !== next.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', () => {
it('renders the optimistic state immediately, then confirms', async () => {
const server = {
toggleLike: vi.fn(async () => ({ liked: true, total: 11 })),
};
render(<LikeButton initialLiked={false} initialTotal={10} server={server} />);
fireEvent.click(screen.getByRole('button'));
// Optimistic frame: button reflects "Unlike (11)" before the server
// resolves — that is what the user sees and feels.
expect(screen.getByRole('button')).toHaveTextContent('Unlike (11)');
await waitFor(() => expect(server.toggleLike).toHaveBeenCalled());
});
it('rolls back to server truth on failure', async () => {
const server = {
toggleLike: vi.fn(async () => {
throw new Error('network down');
}),
};
render(<LikeButton initialLiked={false} initialTotal={10} server={server} />);
fireEvent.click(screen.getByRole('button'));
await waitFor(() =>
// After the rejected mutation drains from the queue, the visible
// state collapses back to the last confirmed snapshot (10, not 11).
expect(screen.getByRole('button')).toHaveTextContent('Like (10)')
);
});
});Interviewer Signal
Shows whether you can explain React behavior while building maintainable product UI.
Constraints
- Name what is render-derived and what is stored state.
- Keep side effects owned by events or effects deliberately.
- Provide a testable boundary for business logic.
Model Answer Shape
- Start from the user workflow and state ownership.
- Move pure decisions out of JSX when the branch logic grows.
- Use React primitives to express ownership, not to hide unclear state.
Tradeoffs
- Colocating state improves clarity until sibling coordination becomes the real problem.
- Memoization helps only after render cost or identity churn is measured.
Edge Cases
- Strict Mode re-running development effects.
- Stale closures after async work resolves.
- Unmounts and route changes during in-flight operations.
Testing And Proof
- Reducer or pure function test for core state transitions.
- Interaction test for the user workflow.
- Regression case for stale or repeated async behavior.
Follow-Ups
- How would this change with server rendering?
- Where would you place this state in a larger app?