Back to Notes

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

  1. In the typeahead problem, why is AbortController necessary 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; AbortController explicitly cancels the outdated in-flight request when a newer one starts.
  2. 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.
  3. 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]]