React Machine-Coding (Frontend System Design)
"Design the frontend for X" rounds — 30-45 min, focused on architecture and data flow, not pixel-level design.
The Interview Framework
1. Clarify requirements (5 min) — functional scope + non-functional (scale, performance, a11y, browser support)
2. Component breakdown (5 min) — draw the tree, identify smart (stateful) vs dumb (presentational) components
3. Data flow (10 min) — where does data come from (API/WebSocket/local), state management, caching strategy
4. API design (5 min) — endpoints needed, pagination? real-time?
5. Performance (5 min) — lazy loading, code splitting, virtualization, debounce/throttle
6. Edge cases + accessibility (5 min) — loading/error/empty states, keyboard nav, ARIA
This mirrors the general [[SD Interview Framework & Pitfalls]] structure applied specifically to a frontend's concerns — requirements first, architecture second, deep-dive the one genuinely hard part third.
Problem 1: Typeahead / Autocomplete
The client-side half of [[Typeahead - Autocomplete]]'s backend design — this is the UI consuming that suggestion API.
SearchBox
├── TextInput
├── SuggestionList → SuggestionItem (xN)
└── LoadingSpinner
Key decisions: debounce (300ms) so the API isn't hit on every keystroke; cancel the previous in-flight request when a new one fires (AbortController) so a slow earlier response can't overwrite a newer one; a minimum character count (2-3) before firing at all; keyboard navigation (ArrowUp/ArrowDown to move selection, Enter to submit, Esc to close).
const cache = new Map();
function useAutocomplete(query) {
const [results, setResults] = useState([]);
const abortRef = useRef();
useEffect(() => {
if (query.length < 2) { setResults([]); return; }
if (cache.has(query)) { setResults(cache.get(query)); return; }
abortRef.current?.abort(); // cancel the previous request — avoid a race
abortRef.current = new AbortController();
fetch(`/api/suggest?q=${query}`, { signal: abortRef.current.signal })
.then(r => r.json())
.then(data => { cache.set(query, data); setResults(data); })
.catch(e => { if (e.name !== 'AbortError') console.error(e); });
}, [query]);
return results;
}
Why AbortController matters, not just debounce: even with debouncing, a slow network response for an earlier query could still resolve after a faster response to a later query — without cancellation, the UI could briefly show stale suggestions for an old query, overwriting the correct current ones. This is the same race-condition-on-async-response class of bug that shows up generally whenever concurrent requests can complete out of order.
Problem 2: Infinite Scroll Feed
Feed
├── PostList → PostCard (xN, virtualized)
├── LoadingIndicator (bottom sentinel)
└── ErrorState
Key decisions: cursor-based pagination, not offset (see [[API Design Principles]] — consistent under concurrently-inserted new posts, exactly the same reasoning as the News Feed HLD problem's timeline pagination); virtualization (react-window) so the DOM only ever holds the currently-visible rows, not every loaded item (see [[React Performance & Reconciliation]]); IntersectionObserver on a sentinel element at the list's bottom to trigger the next page load, rather than a scroll-position calculation.
function Feed() {
const [posts, setPosts] = useState([]);
const [cursor, setCursor] = useState(null);
const sentinelRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && cursor !== null) loadMore();
});
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [cursor]);
async function loadMore() {
const { data, next_cursor } = await fetchPosts(cursor);
setPosts(prev => [...prev, ...data]);
setCursor(next_cursor);
}
return (
<div>
{posts.map(p => <PostCard key={p.id} post={p} />)}
<div ref={sentinelRef} />
</div>
);
}
Problem 3: Real-Time Dashboard (WebSocket)
Dashboard
├── MetricCard (xN) — each subscribes to its own specific metric
├── Chart (live-updating)
└── ConnectionStatus
Key decisions: one WebSocket connection shared across all widgets, not one per widget (the same connection-multiplexing reasoning as [[WhatsApp Chat]]'s per-user connection, applied to per-page instead of per-user); a subscribe/unsubscribe pattern so individual widgets can opt into just the message types they care about without the connection layer knowing about UI structure; reconnect with exponential backoff on disconnect (the identical backoff pattern as [[Distributed Job Scheduler]]'s retry logic); stale-while-revalidate — show the last known value while reconnecting rather than blanking the UI.
function useWebSocket(url) {
const [status, setStatus] = useState('connecting');
const wsRef = useRef();
const listeners = useRef(new Map());
const retries = useRef(0);
function connect() {
const ws = new WebSocket(url);
ws.onopen = () => { setStatus('open'); retries.current = 0; };
ws.onclose = () => {
setStatus('closed');
setTimeout(connect, Math.min(1000 * 2 ** retries.current++, 30000)); // exponential backoff, capped
};
ws.onmessage = ({ data }) => {
const { type, payload } = JSON.parse(data);
listeners.current.get(type)?.forEach(fn => fn(payload));
};
wsRef.current = ws;
}
function subscribe(event, fn) {
if (!listeners.current.has(event)) listeners.current.set(event, new Set());
listeners.current.get(event).add(fn);
return () => listeners.current.get(event)?.delete(fn); // unsubscribe cleanup
}
useEffect(() => { connect(); return () => wsRef.current?.close(); }, []);
return { status, subscribe };
}
Why one shared connection instead of one per widget: each WebSocket connection has real overhead (a TCP handshake, a persistent server-side resource) — a dashboard with 20 metric widgets each opening its own connection multiplies that cost 20x for no benefit, since a single connection can multiplex every message type the dashboard needs via the subscribe/unsubscribe layer above it.
Problem 4: Complex Multi-Step Form (Checkout/Onboarding)
Key decisions: controlled inputs specifically where validation needs to react per-keystroke, uncontrolled where a field is performance-sensitive and nothing else needs its interim value (see [[React Patterns & State Management]]'s controlled-vs-uncontrolled decision); validate on blur, not on every keystroke — interrupting a user mid-type with an error is a worse experience than validating once they've moved on; disable the submit button immediately on first click (optimistic, prevents duplicate submission — the client-side echo of [[API Design Principles]]'s server-side idempotency-key defense against the same duplicate-submission problem); field-level error state, not just one form-level error, so a user can see exactly which fields need fixing; preserve state across steps via Context or a lightweight store rather than losing it on navigation between steps.
Performance & State Management Reference
See [[React Performance & Reconciliation]] for the general performance-pattern table and [[React Patterns & State Management]]'s state-management decision tree — both apply directly to every problem above (all four use virtualization, memoization, or a specific state-management tier as a core design decision, not an afterthought).
Interview Drills
- In the typeahead problem, why is
AbortControllernecessary even with a 300ms debounce already in place? — Debouncing controls how often a request fires, but says nothing about how long a fired request takes to resolve — a slow response to an earlier query can still arrive after a fast response to a later one, and without cancellation the UI would briefly display results for the wrong (stale) query;AbortControllerexplicitly cancels the outdated in-flight request when a newer one starts. - Why does the infinite-scroll feed use cursor pagination instead of offset pagination? — New posts can be inserted at the top of the feed between page loads — offset pagination would either skip or duplicate rows as the underlying "position 20" shifts due to new inserts ahead of it; cursor pagination anchors to a specific post ID/timestamp, which stays stable regardless of what's inserted elsewhere — identical reasoning to the News Feed HLD problem's timeline pagination.
- Why does the WebSocket dashboard use one shared connection with a subscribe/unsubscribe layer instead of letting each widget manage its own WebSocket? — Each connection carries real overhead (handshake, persistent server resource); a dashboard with many widgets opening independent connections multiplies that cost without benefit, since a single connection can carry every message type needed — the subscribe/unsubscribe layer routes incoming messages to interested widgets without each widget needing its own transport.
Cross-links
[[Typeahead - Autocomplete]] · [[News Feed - Twitter Feed]] · [[WhatsApp Chat]] · [[React Performance & Reconciliation]] · [[React Patterns & State Management]] · [[API Design Principles]] · [[SD Interview Framework & Pitfalls]]