Become a Professional Frontend Developer
9 min read

The UI Coding Round: Three Builds, Done Right

The frontend coding round decoded — the anatomy of a strong UI build answer (clarify, build behavior, handle loading/empty/error, add accessibility, narrate throughout), then three worked React builds interviewers love: a debounced accessible autocomplete with request cancellation, a keyboard-navigable tabs component following the ARIA pattern, and a sortable/filterable/paginated data table. With the edge cases and a11y that separate a pass from a strong pass.

The UI coding round is the one that's least like LeetCode and most like the job: build a real, working component in 40-odd minutes while talking through your decisions. A working feature is table stakes — what earns a strong score is handling the states nobody demos (loading, empty, error), making it keyboard-accessible, and narrating why the whole time. This post gives you the anatomy of a good answer and three fully worked builds interviewers reach for. It's round 3 of the Airbnb interview map, and it builds on React fundamentals, state & hooks, forms, and async/await.

The mental model: a passing answer makes the happy path work; a strong answer handles the unhappy paths and accessibility, and narrates the reasoning. Same feature, very different score. Before writing a line, clarify requirements; while building, say what you're doing and why; before declaring done, walk the loading/empty/error states and the keyboard.

The Anatomy of a Strong UI Build

Whatever the prompt, run this sequence out loud — it's the component analog of the system-design framework:

  1. Clarify. What exactly should it do? What are the inputs and constraints? "Should selecting a suggestion fill the input or navigate?" Two minutes of questions prevents building the wrong thing.
  2. Sketch the API and state. Name the props/component interface and the minimal state. The most common mistake is too much state — derive what you can instead of storing it.
  3. Build the behavior first. Get the core interaction working before styling. Structure it into small, reusable pieces as you go.
  4. Handle the three non-happy states. Loading, empty, and error — real UI spends most of its code here, and mentioning them unprompted is a senior signal.
  5. Add accessibility. Semantic elements, keyboard operation, and the right ARIA pattern. For interactive widgets this is part of "does it work," not a bonus.
  6. Narrate throughout. The interviewer grades your reasoning; silent coding gives them nothing to grade.

Build 1 — Debounced Accessible Autocomplete

The single most-asked build, because it touches async, performance, and accessibility at once.

import { useState, useEffect, useRef } from "react";

function Autocomplete({ fetchSuggestions }) {
  const [query, setQuery] = useState("");
  const [items, setItems] = useState([]);
  const [status, setStatus] = useState("idle"); // idle|loading|error|done
  const [active, setActive] = useState(-1);      // highlighted index
  const [open, setOpen] = useState(false);

  useEffect(() => {
    if (!query.trim()) { setItems([]); setStatus("idle"); return; }
    const controller = new AbortController();
    const id = setTimeout(async () => {          // debounce ~250ms
      setStatus("loading");
      try {
        const results = await fetchSuggestions(query, controller.signal);
        setItems(results);
        setStatus("done");
        setActive(-1);
      } catch (e) {
        if (e.name !== "AbortError") setStatus("error");
      }
    }, 250);
    return () => { clearTimeout(id); controller.abort(); }; // cancel stale
  }, [query, fetchSuggestions]);

  function onKeyDown(e) {
    if (!open) return;
    if (e.key === "ArrowDown") setActive(i => Math.min(i + 1, items.length - 1));
    else if (e.key === "ArrowUp") setActive(i => Math.max(i - 1, 0));
    else if (e.key === "Enter" && active >= 0) choose(items[active]);
    else if (e.key === "Escape") setOpen(false);
  }
  function choose(item) { setQuery(item.label); setOpen(false); }

  return (
    <div className="ac">
      <input
        role="combobox" aria-expanded={open} aria-controls="ac-list"
        aria-activedescendant={active >= 0 ? `ac-opt-${active}` : undefined}
        value={query} onChange={e => { setQuery(e.target.value); setOpen(true); }}
        onKeyDown={onKeyDown} aria-autocomplete="list"
      />
      {open && (
        <ul id="ac-list" role="listbox">
          {status === "loading" && <li aria-disabled>Loading…</li>}
          {status === "error" && <li aria-disabled>Something went wrong.</li>}
          {status === "done" && items.length === 0 && <li aria-disabled>No results</li>}
          {items.map((item, i) => (
            <li key={item.id} id={`ac-opt-${i}`} role="option"
                aria-selected={i === active}
                onMouseDown={() => choose(item)}>
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

What earns the strong score here: the setTimeout debounce so you query on pause not per keystroke; the AbortController cleanup that cancels stale requests (the classic race where a slow older response overwrites a newer one); explicit loading / empty / error rendering; and the full ARIA combobox pattern with ArrowUp/ArrowDown/Enter/Escape. Call each of these out as you add it. (onMouseDown not onClick, so selection fires before the input blurs.)

Build 2 — Accessible Tabs

Deceptively simple — the signal is whether you implement the real ARIA tabs keyboard pattern, not just click handlers.

import { useState, useRef } from "react";

function Tabs({ tabs }) {          // tabs: [{ id, label, content }]
  const [selected, setSelected] = useState(0);
  const refs = useRef([]);

  function onKeyDown(e) {
    let next = null;
    if (e.key === "ArrowRight") next = (selected + 1) % tabs.length;
    else if (e.key === "ArrowLeft") next = (selected - 1 + tabs.length) % tabs.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = tabs.length - 1;
    if (next !== null) {
      e.preventDefault();
      setSelected(next);
      refs.current[next]?.focus();   // roving tabindex: move focus with selection
    }
  }

  return (
    <div className="tabs">
      <div role="tablist" aria-label="Sections" onKeyDown={onKeyDown}>
        {tabs.map((t, i) => (
          <button
            key={t.id} role="tab" id={`tab-${t.id}`}
            aria-selected={i === selected} aria-controls={`panel-${t.id}`}
            tabIndex={i === selected ? 0 : -1}   // only the active tab is tab-focusable
            ref={el => (refs.current[i] = el)}
            onClick={() => setSelected(i)}
          >
            {t.label}
          </button>
        ))}
      </div>
      {tabs.map((t, i) => (
        <div
          key={t.id} role="tabpanel" id={`panel-${t.id}`}
          aria-labelledby={`tab-${t.id}`} hidden={i !== selected}
        >
          {t.content}
        </div>
      ))}
    </div>
  );
}

What earns the strong score: the roving tabindex (only the selected tab has tabIndex={0}; the rest are -1), arrow-key navigation with Home/End, the role/aria-selected/aria-controls/aria-labelledby wiring, and hidden on inactive panels. Mention that this follows the WAI-ARIA tabs pattern — naming the standard is itself a signal.

Build 3 — Sortable, Filterable, Paginated Table

The test here is state discipline: keep one source of truth and derive the view, rather than storing a separate copy you have to keep in sync.

import { useState, useMemo } from "react";

function DataTable({ rows, columns, pageSize = 10 }) {
  const [sort, setSort] = useState({ key: null, dir: "asc" });
  const [filter, setFilter] = useState("");
  const [page, setPage] = useState(0);

  // Derive the visible rows from source state — never store a second copy.
  const view = useMemo(() => {
    let out = rows;
    if (filter) {
      const q = filter.toLowerCase();
      out = out.filter(r =>
        columns.some(c => String(r[c.key]).toLowerCase().includes(q)));
    }
    if (sort.key) {
      out = [...out].sort((a, b) => {
        const [x, y] = [a[sort.key], b[sort.key]];
        const cmp = x < y ? -1 : x > y ? 1 : 0;
        return sort.dir === "asc" ? cmp : -cmp;
      });
    }
    return out;
  }, [rows, columns, filter, sort]);

  const pageCount = Math.max(1, Math.ceil(view.length / pageSize));
  const clamped = Math.min(page, pageCount - 1);
  const pageRows = view.slice(clamped * pageSize, clamped * pageSize + pageSize);

  function toggleSort(key) {
    setSort(s => s.key === key
      ? { key, dir: s.dir === "asc" ? "desc" : "asc" }
      : { key, dir: "asc" });
    setPage(0);
  }

  return (
    <div>
      <input placeholder="Filter…" value={filter}
             onChange={e => { setFilter(e.target.value); setPage(0); }} />
      <table>
        <thead>
          <tr>{columns.map(c => (
            <th key={c.key} aria-sort={sort.key === c.key
                  ? (sort.dir === "asc" ? "ascending" : "descending") : "none"}>
              <button onClick={() => toggleSort(c.key)}>{c.label}</button>
            </th>
          ))}</tr>
        </thead>
        <tbody>
          {pageRows.length === 0
            ? <tr><td colSpan={columns.length}>No matching rows</td></tr>
            : pageRows.map(r => (
                <tr key={r.id}>{columns.map(c => <td key={c.key}>{r[c.key]}</td>)}</tr>
              ))}
        </tbody>
      </table>
      <nav aria-label="Pagination">
        <button disabled={clamped === 0} onClick={() => setPage(clamped - 1)}>Prev</button>
        <span>Page {clamped + 1} of {pageCount}</span>
        <button disabled={clamped >= pageCount - 1} onClick={() => setPage(clamped + 1)}>Next</button>
      </nav>
    </div>
  );
}

What earns the strong score: deriving view with useMemo from the source rows instead of duplicating state; resetting to page 0 when the filter or sort changes; the empty state row; aria-sort on the headers and real <button>s for sortable columns; and clamping the page so data changes can't strand you on a nonexistent page. Say why you derive rather than store — conflating source and view state is the classic table bug.

Utility Warm-ups

Before the main build, some interviewers throw a small pure-JS function — debounce, throttle, Promise.all, deep clone, curry, memoize, an event emitter. These are quick if you've practiced them and awkward if you haven't; every one is worked through in JavaScript coding problems. Warm up on a couple the night before.

Common Mistakes

  • Skipping clarification — building on assumptions instead of asking. Two minutes up front saves the whole round.
  • Only the happy path — no loading, empty, or error handling. Real components spend most of their code there.
  • No accessibility — mouse-only widgets with no keyboard support or ARIA. For interactive UI, that's incomplete, not polished-later.
  • State duplication — storing a filtered/sorted copy alongside the source and fighting to keep them in sync. Derive instead.
  • Premature abstraction — building a generic framework when a focused component was asked for. Ship the thing, then discuss how you'd generalize.
  • Silent coding — the interviewer can't grade reasoning they can't hear. Narrate.

Exercises

Sketch each before opening the answer — say your plan out loud.

Exercise 1 — Debounce vs throttle

For the autocomplete, why debounce the input rather than throttle it?

Show answer

Debounce fires after the user pauses typing — exactly when you want to query, since mid-word keystrokes produce useless requests. Throttle fires at a fixed rate during typing, so it would query on partial words and waste calls. Debounce for "act when input settles"; throttle for "act at most every N ms during a continuous stream" (scroll, resize). Naming that distinction is the point of the question.

Exercise 2 — The stale-response race

Even with debounce, why do you still need AbortController?

Show answer

Debounce reduces requests but doesn't order responses. If you type "re" then "react", both may be in flight, and the slower "re" response can arrive last and overwrite the correct "react" results — a race condition. Cancelling the previous request on each new one (or ignoring responses whose query no longer matches the current input) guarantees the UI reflects the latest query. This is the bug that separates a working demo from a correct one.

Exercise 3 — Where should sort state live?

In the data table, why derive the sorted rows with useMemo instead of storing a sortedRows state?

Show answer

sortedRows would be a second copy of data that must be re-synced every time rows, the filter, or the sort key changes — miss one update and the table shows stale data. Deriving with useMemo keeps a single source of truth (rows) and recomputes the view only when its inputs change. The rule: if a value can be computed from existing state, don't store it — derive it. That discipline is exactly what this build is testing.

The Mental Model to Keep

The UI coding round rewards product-shaped engineering, narrated out loud. Run the same sequence every time: clarify the requirements, sketch the minimal API and state, build the behavior first, then handle the three non-happy states — loading, empty, error — and layer on accessibility with the right ARIA pattern and keyboard support. Across the three canonical builds, the strong-answer details repeat: debounce plus request cancellation for async input, roving tabindex and arrow keys for widgets, and deriving view state instead of duplicating it. A working feature passes; the edge cases, the accessibility, and the running commentary are what make it a strong pass. Build the thing, but never in silence and never only for the happy path.