React Machine Coding Round: Build These Six Components
The hands-on round — debounced search with cancellation, a paginated list, an accessible modal, form validation, a stopwatch, and infinite scroll. Full working implementations plus what the interviewer is scoring.
4 sections · ~45 min · 5-question quiz (pass ≥ 70%)
1How the Machine Coding Round Is Scored
You get 30–60 minutes and a prompt like "build an autocomplete search". The working feature is table stakes; the score comes from what you do around it.
What the interviewer is actually checking:
- Do you clarify before coding? Two minutes of questions — "Should search hit an API or filter a local list? Do results need keyboard navigation? Is loading state needed?" — is the strongest opening move available.
- Component decomposition. One 200-line component scores far below a small tree of focused ones with a custom hook for the logic.
- The three states. Loading, empty, and error. Most candidates ship only the happy path; handling all three is the cheapest differentiator in the round.
- Cleanup. Cancelled timers, aborted fetches, removed listeners. Its absence is read as inexperience.
- Accessibility basics. A real
<button>rather than a clickable<div>, labels tied to inputs,aria-labelon icon buttons, visible focus. - Narration. Say what you are doing and why. Silence reads as uncertainty even when the code is right.
Time budget that works: ~5 min clarifying and sketching structure → ~30 min core implementation → ~10 min edge cases and polish → ~5 min walking through your own code and naming what you would add with more time.
Say the trade-offs out loud. "I'm keeping this in local state; if it needed to be shared across routes I'd lift it to context or a store." That single sentence often matters more than the feature itself.
2Debounced Search with Cancellation
The most-asked machine coding problem. It tests debouncing, effect cleanup, race conditions, and loading states all at once.
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // reset the timer on each keystroke
}, [value, delay]);
return debounced;
}
function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [status, setStatus] = useState("idle"); // idle | loading | error | done
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (!debouncedQuery.trim()) {
setResults([]);
setStatus("idle");
return;
}
const controller = new AbortController();
setStatus("loading");
fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`,
{ signal: controller.signal })
.then((r) => {
if (!r.ok) throw new Error(`Request failed: ${r.status}`);
return r.json();
})
.then((data) => { setResults(data); setStatus("done"); })
.catch((err) => { if (err.name !== "AbortError") setStatus("error"); });
return () => controller.abort(); // cancel the in-flight request
}, [debouncedQuery]);
return (
<div>
<label htmlFor="search">Search</label>
<input
id="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products…"
/>
{status === "loading" && <p>Loading…</p>}
{status === "error" && <p role="alert">Something went wrong. Try again.</p>}
{status === "done" && results.length === 0 && <p>No results for “{debouncedQuery}”.</p>}
<ul>
{results.map((r) => <li key={r.id}>{r.name}</li>)}
</ul>
</div>
);
}
Points scored here: debounce extracted into a reusable hook, AbortController for cancellation, a single status value instead of three booleans that can contradict each other, trimmed empty query short-circuit, a labelled input, and a distinct empty state.
Likely follow-ups: "Add keyboard navigation" (track a highlightedIndex, handle ArrowUp/ArrowDown/Enter/Escape). "What's the difference between debounce and throttle?" — debounce waits for a pause in activity; throttle guarantees at most one call per interval. Search wants debounce; scroll and resize handlers want throttle.
3Pagination and Infinite Scroll
Paginated list — a clean, complete implementation:
function PaginatedList({ pageSize = 10 }) {
const [page, setPage] = useState(1);
const [data, setData] = useState({ items: [], total: 0 });
const [loading, setLoading] = useState(false);
useEffect(() => {
let ignore = false;
setLoading(true);
fetch(`/api/items?page=${page}&limit=${pageSize}`)
.then((r) => r.json())
.then((d) => { if (!ignore) setData(d); })
.finally(() => { if (!ignore) setLoading(false); });
return () => { ignore = true; };
}, [page, pageSize]);
const totalPages = Math.max(1, Math.ceil(data.total / pageSize));
return (
<>
{loading ? <Skeleton /> : (
<ul>{data.items.map((i) => <li key={i.id}>{i.name}</li>)}</ul>
)}
<nav aria-label="Pagination">
<button onClick={() => setPage((p) => p - 1)} disabled={page === 1}>
Previous
</button>
<span>Page {page} of {totalPages}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={page >= totalPages}
>
Next
</button>
</nav>
</>
);
}
Note totalPages is derived during render, not stored in state — exactly the design point from the core concepts course.
Infinite scroll with IntersectionObserver (better than a scroll listener — no throttling needed, and it does not run on the main thread on every pixel):
function InfiniteList() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const sentinelRef = useRef(null);
useEffect(() => {
fetch(`/api/items?page=${page}`)
.then((r) => r.json())
.then((d) => {
setItems((prev) => [...prev, ...d.items]); // updater: never stale
setHasMore(d.items.length > 0);
});
}, [page]);
useEffect(() => {
const node = sentinelRef.current;
if (!node || !hasMore) return;
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setPage((p) => p + 1); },
{ rootMargin: "200px" } // prefetch before it is visible
);
observer.observe(node);
return () => observer.disconnect(); // cleanup
}, [hasMore]);
return (
<>
<ul>{items.map((i) => <li key={i.id}>{i.name}</li>)}</ul>
{hasMore && <div ref={sentinelRef} aria-hidden="true" />}
</>
);
}
Follow-up to be ready for: "What if the list is 100,000 rows?" → virtualization (render only the visible window; react-window / @tanstack/react-virtual).
4Modal, Form Validation, and Stopwatch
Accessible modal — portal, Escape key, scroll lock, backdrop click:
function Modal({ isOpen, onClose, title, children }) {
useEffect(() => {
if (!isOpen) return;
const onKey = (e) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow; // restore, don't hardcode
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="backdrop" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()} // don't close on inner clicks
>
<h2>{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.body
);
}
Mention focus trapping and restoring focus to the trigger on close even if you do not have time to implement it — knowing it exists is most of the credit.
Form with validation — validate on blur and on submit, not on every keystroke:
function SignupForm({ onSubmit }) {
const [values, setValues] = useState({ email: "", password: "" });
const [touched, setTouched] = useState({});
const errors = { // derived, not state
email: !values.email ? "Email is required"
: !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(values.email) ? "Enter a valid email"
: null,
password: values.password.length < 8 ? "At least 8 characters" : null,
};
const isValid = Object.values(errors).every((e) => e === null);
const change = (e) =>
setValues((v) => ({ ...v, [e.target.name]: e.target.value }));
function handleSubmit(e) {
e.preventDefault();
setTouched({ email: true, password: true });
if (isValid) onSubmit(values);
}
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email">Email</label>
<input id="email" name="email" value={values.email} onChange={change}
onBlur={() => setTouched((t) => ({ ...t, email: true }))}
aria-invalid={Boolean(touched.email && errors.email)} />
{touched.email && errors.email && <span role="alert">{errors.email}</span>}
<button type="submit" disabled={!isValid}>Sign up</button>
</form>
);
}
Errors are computed during render from values — no useEffect syncing an errors state. That is the detail a strong reviewer looks for.
Stopwatch — timers, refs, and cleanup:
function Stopwatch() {
const [ms, setMs] = useState(0);
const [running, setRunning] = useState(false);
const intervalRef = useRef(null);
useEffect(() => {
if (!running) return;
intervalRef.current = setInterval(() => setMs((m) => m + 10), 10);
return () => clearInterval(intervalRef.current); // stop & on unmount
}, [running]);
const seconds = (ms / 1000).toFixed(2); // derived
return (
<>
<output>{seconds}s</output>
<button onClick={() => setRunning((r) => !r)}>
{running ? "Pause" : "Start"}
</button>
<button onClick={() => { setRunning(false); setMs(0); }}>Reset</button>
</>
);
}
Note the honest caveat to volunteer: setInterval drifts. For an accurate timer, store a start timestamp and compute elapsed time from Date.now() on each tick. Saying that unprompted is a senior-level signal.