Machine Coding: Building UI Components Under Time Pressure
Machine-coding rounds give you 45 to 90 minutes to build a working component: an autocomplete, a star rating, tabs, an accordion, a modal, a todo list, a data table, infinite scroll, a progress bar, a tic-tac-toe board. The interviewers are not grading the cleverest solution. They are checking approach, state design, correctness at the edges, code quality and accessibility. This chapter gives a method, then breaks down the most common components, separating the logic (which you can write as plain, testable functions) from the view.
1. The method
- Clarify (3 to 5 minutes). Required features, data source and size, keyboard and mobile behaviour, accessibility expectations, styling freedom, frameworks allowed. Write the list down.
- Design the state. What is the minimum state? What is derived? Draw the component tree and who owns what.
- Build the skeleton first. Static markup with real semantics, then wire one behaviour at a time. Get something working by minute 20.
- Keep logic pure. Put decisions (next index, filtered list, reducer) in functions that take inputs and return outputs. They are easy to reason about and to test.
- Handle edges: empty, loading, error, rapid input, long text, disabled items, wrap-around.
- Add accessibility and keyboard support. Mention it even if time is short.
- Narrate trade-offs and what you would do next (debounce, virtualise, test, memoise).
A useful stance: "I will build the simple version, then list the improvements I would make."
2. Component 1: star rating
Requirements: show N stars, hover previews, click sets the value, click again clears (optional), keyboard arrows adjust, accessible name.
State: value (committed) and hover (preview, nullable). Derived: the displayed value is hover ?? value.
function ratingDisplay(value, hover) { return hover ?? value; }
function clampRating(n, max) { return Math.min(max, Math.max(0, n)); }
function ratingKey(value, key, max) {
if (key === "ArrowRight" || key === "ArrowUp") return clampRating(value + 1, max);
if (key === "ArrowLeft" || key === "ArrowDown") return clampRating(value - 1, max);
if (key === "Home") return 0;
if (key === "End") return max;
return value;
}
assert.equal(ratingDisplay(2, null), 2);
assert.equal(ratingDisplay(2, 4), 4); // hovering previews 4 stars
assert.equal(ratingKey(5, "ArrowRight", 5), 5); // clamped at the maximum
assert.equal(ratingKey(0, "ArrowLeft", 5), 0);
assert.equal(ratingKey(3, "End", 5), 5);
Markup: a role="radiogroup" with role="radio" children, or real radio inputs visually styled as stars (the most robust). Edge cases: half stars (compute from pointer position), read-only mode, clearing, touch devices with no hover.
3. Component 2: tabs
State: activeIndex. Keyboard: arrows move between tabs (roving tabindex), Home and End jump. Accessibility: role="tablist", role="tab" with aria-selected and aria-controls, role="tabpanel" with aria-labelledby. Decide between automatic activation (focus selects) and manual activation (Enter or Space selects).
function nextTab(current, key, count, disabled = new Set()) {
const step = key === "ArrowRight" ? 1 : key === "ArrowLeft" ? -1 : 0;
if (key === "Home") return [...Array(count).keys()].find(i => !disabled.has(i)) ?? current;
if (key === "End") return [...Array(count).keys()].reverse().find(i => !disabled.has(i)) ?? current;
if (!step) return current;
for (let n = 1; n <= count; n++) { // skip disabled tabs, wrapping around
const i = (current + step * n + count * n) % count;
if (!disabled.has(i)) return i;
}
return current;
}
assert.equal(nextTab(0, "ArrowRight", 4), 1);
assert.equal(nextTab(3, "ArrowRight", 4), 0); // wraps
assert.equal(nextTab(0, "ArrowLeft", 4), 3);
assert.equal(nextTab(0, "ArrowRight", 4, new Set([1])), 2); // skips the disabled tab
assert.equal(nextTab(2, "Home", 4, new Set([0])), 1);
assert.equal(nextTab(1, "End", 4), 3);
4. Component 3: accordion and disclosure
State: a set of open item IDs (multiple open) or one ID (single open). A set generalises both. Prefer native <details> when allowed.
function toggleItem(open, id, single) {
const next = new Set(open);
if (next.has(id)) { next.delete(id); return next; }
if (single) next.clear(); // single-expand mode closes the others
next.add(id);
return next;
}
let open = new Set();
open = toggleItem(open, "a", false); open = toggleItem(open, "b", false);
assert.deepEqual([...open].sort(), ["a", "b"]);
open = toggleItem(open, "c", true);
assert.deepEqual([...open], ["c"]);
open = toggleItem(open, "c", true);
assert.equal(open.size, 0);
5. Component 4: autocomplete / typeahead
The classic. Break it into pieces.
Requirements: type to filter, show suggestions, arrow keys to move, Enter to select, Esc to close, click to select, highlight the matching text, handle slow and out-of-order responses, no result state.
State: query, results, status (idle, loading, error), open, activeIndex. Derived: the visible list.
Behaviour to get right:
- Debounce the query (200 to 300 ms) before searching.
- Cancel or ignore stale responses (AbortController or a request ID).
- Cache previous queries.
- Keyboard: Down and Up move
activeIndex(wrap or clamp), Enter selects, Esc closes, Tab leaves. - Accessibility:
role="combobox",aria-expanded,aria-controls,aria-activedescendant, arole="listbox"withrole="option"items, and anaria-liveannouncement of the result count.
function highlight(text, query) {
if (!query) return [{ text, match: false }];
const i = text.toLowerCase().indexOf(query.toLowerCase());
if (i < 0) return [{ text, match: false }];
return [
{ text: text.slice(0, i), match: false },
{ text: text.slice(i, i + query.length), match: true },
{ text: text.slice(i + query.length), match: false },
].filter(p => p.text);
}
assert.deepEqual(highlight("JavaScript", "script"), [{ text: "Java", match: false }, { text: "Script", match: true }]);
assert.deepEqual(highlight("Java", "x"), [{ text: "Java", match: false }]);
function moveActive(active, key, count) {
if (count === 0) return -1;
if (key === "ArrowDown") return active + 1 >= count ? 0 : active + 1; // wrap to the top
if (key === "ArrowUp") return active <= 0 ? count - 1 : active - 1; // wrap to the bottom
return active;
}
assert.equal(moveActive(-1, "ArrowDown", 3), 0);
assert.equal(moveActive(2, "ArrowDown", 3), 0);
assert.equal(moveActive(0, "ArrowUp", 3), 2);
assert.equal(moveActive(0, "ArrowDown", 0), -1);
// a request manager that debounces, caches and discards stale responses
function createTypeahead(fetcher, { wait = 10 } = {}) {
const cache = new Map();
let timer, latest = 0;
return {
results: [],
query(q) {
clearTimeout(timer);
return new Promise(resolve => {
timer = setTimeout(async () => {
const id = ++latest;
const data = cache.get(q) ?? await fetcher(q);
cache.set(q, data);
if (id === latest) this.results = data; // ignore stale responses
resolve(id === latest);
}, wait);
});
},
};
}
const calls = [];
const ta = createTypeahead(async q => { calls.push(q); return [q + "1", q + "2"]; });
ta.query("a"); ta.query("ab"); // fast typing: the first call is superseded
const applied = await ta.query("abc");
assert.equal(applied, true);
assert.deepEqual(calls, ["abc"]); // only one request after debouncing
assert.deepEqual(ta.results, ["abc1", "abc2"]);
Follow-ups to mention: minimum query length, trimming input, highlighting, keyboard focus stays in the input (use aria-activedescendant rather than moving focus), IME composition (do not search mid-composition), mobile virtual keyboards, large result lists (virtualise), recent searches.
6. Component 5: modal / dialog
Behaviour: opens over the page, traps focus, closes on Esc and backdrop click, restores focus to the trigger, prevents background scroll, announces itself (role="dialog", aria-modal="true", a label). Render in a portal so it escapes overflow and stacking contexts. The native <dialog> with showModal() provides much of this.
7. Component 6: todo list
State is an array of { id, text, done }. Operations: add, toggle, edit, remove, filter (all, active, done), clear completed, persist to localStorage. Use stable IDs (not indexes) for keys. A reducer keeps the logic testable.
function todos(state, a) {
switch (a.type) {
case "add": return a.text.trim() ? [...state, { id: a.id, text: a.text.trim(), done: false }] : state; // ignore blank input
case "toggle": return state.map(t => (t.id === a.id ? { ...t, done: !t.done } : t));
case "edit": return state.map(t => (t.id === a.id ? { ...t, text: a.text.trim() || t.text } : t));
case "remove": return state.filter(t => t.id !== a.id);
case "clearDone": return state.filter(t => !t.done);
default: return state;
}
}
const visible = (list, filter) => list.filter(t => (filter === "active" ? !t.done : filter === "done" ? t.done : true));
let list = [];
list = todos(list, { type: "add", id: 1, text: " buy milk " });
list = todos(list, { type: "add", id: 2, text: " " }); // blank: ignored
list = todos(list, { type: "add", id: 3, text: "call dad" });
list = todos(list, { type: "toggle", id: 1 });
assert.deepEqual(list.map(t => t.text), ["buy milk", "call dad"]);
assert.deepEqual(visible(list, "active").map(t => t.id), [3]);
assert.deepEqual(visible(todos(list, { type: "clearDone" }), "all").map(t => t.id), [3]);
8. Component 7: infinite scroll and pagination
Approach: an IntersectionObserver watches a sentinel element at the end of the list; when it becomes visible and no request is in flight and more pages exist, fetch the next page and append. Track cursor, hasMore, loading, error. Guard against duplicate requests, handle failure with a retry, preserve scroll position, and virtualise when the list grows large.
function createPager(fetchPage) {
const state = { items: [], cursor: null, hasMore: true, loading: false };
return {
state,
async loadMore() {
if (state.loading || !state.hasMore) return false; // guard against duplicate triggers
state.loading = true;
try {
const { items, next } = await fetchPage(state.cursor);
state.items = [...state.items, ...items];
state.cursor = next; state.hasMore = next !== null;
} finally { state.loading = false; }
return true;
},
};
}
const pages = { null: { items: [1, 2], next: "p2" }, p2: { items: [3, 4], next: "p3" }, p3: { items: [5], next: null } };
const pager = createPager(async c => pages[c]);
const [first, dup] = await Promise.all([pager.loadMore(), pager.loadMore()]); // two triggers at once
assert.equal(first, true); assert.equal(dup, false); // the duplicate is ignored
await pager.loadMore(); await pager.loadMore();
assert.deepEqual(pager.state.items, [1, 2, 3, 4, 5]);
assert.equal(pager.state.hasMore, false);
assert.equal(await pager.loadMore(), false);
9. Component 8: data table with sort, filter and pagination
State: rows, sort: { key, dir }, filter, page, pageSize. Derived: filtered, then sorted, then sliced. Order matters: filter before sort before paginate. Sort stably, treat numbers and strings differently, keep headers as buttons with aria-sort.
function tableView(rows, { filter = "", sortKey, dir = "asc", page = 1, pageSize = 2 }) {
let out = rows.filter(r => Object.values(r).some(v => String(v).toLowerCase().includes(filter.toLowerCase())));
if (sortKey) {
const sign = dir === "asc" ? 1 : -1;
out = [...out].sort((a, b) => {
const x = a[sortKey], y = b[sortKey];
return (typeof x === "number" && typeof y === "number" ? x - y : String(x).localeCompare(String(y))) * sign;
});
}
const totalPages = Math.max(1, Math.ceil(out.length / pageSize));
const current = Math.min(page, totalPages); // clamp when a filter shrinks the data
return { rows: out.slice((current - 1) * pageSize, current * pageSize), totalPages, page: current };
}
const data = [{ n: "Ravi", age: 31 }, { n: "Asha", age: 25 }, { n: "Meera", age: 28 }, { n: "Zoya", age: 25 }];
assert.deepEqual(tableView(data, { sortKey: "age" }).rows.map(r => r.n), ["Asha", "Zoya"]); // stable on ties
assert.deepEqual(tableView(data, { sortKey: "n", dir: "desc", page: 2 }).rows.map(r => r.n), ["Meera", "Asha"]);
assert.equal(tableView(data, { filter: "zz" }).totalPages, 1);
assert.equal(tableView(data, { page: 9 }).page, 2); // an out-of-range page is clamped
10. Component 9: a progress bar, a stepper, a toast queue
Smaller components test your attention to detail: clamp values to 0 to 100, expose role="progressbar" with aria-valuenow; a stepper disables unreachable steps and validates before advancing; a toast queue limits the visible count, auto-dismisses with a pause on hover, and announces through a polite live region.
const clamp01 = n => Math.min(100, Math.max(0, Math.round(n)));
assert.deepEqual([-5, 0, 49.6, 100, 140].map(clamp01), [0, 0, 50, 100, 100]);
function pushToast(queue, toast, max = 3) {
const next = [...queue, toast];
return next.length > max ? next.slice(next.length - max) : next; // drop the oldest beyond the limit
}
assert.deepEqual(pushToast([1, 2, 3], 4), [2, 3, 4]);
11. Code quality signals interviewers notice
- Naming that explains intent; small functions; no giant component with everything inline.
- No duplicated state, no mutation of props or state.
- Separation: logic in pure functions or hooks, rendering in components.
- Event handling hygiene: cleanup of listeners and timers,
keyon lists,type="button"on non-submit buttons. - Defensive handling: empty arrays, null data, double clicks, rapid typing.
- Accessibility from the start, not as an afterthought.
- Time management: a working core before polish; a clear list of what you would add.
12. Common mistakes
- Starting with styling and running out of time for behaviour.
- Storing derived state (a
filteredListthat drifts). - Index keys in dynamic lists.
- No loading, empty or error state.
- Forgetting stale-response and debounce handling in search UIs.
- Mouse-only interaction.
- A monolithic component that no one can extend.
- Silence: not explaining decisions or edge cases.
13. Practice set
Build each in under an hour without copying: star rating, tabs, accordion, autocomplete, modal with focus trap, todo list with persistence, infinite scroll feed, sortable and paginated table, image carousel, file upload with progress, tic-tac-toe, a digital clock and stopwatch, nested comments, a Kanban board with drag and drop, a calendar date picker.