Your Day 1 task at a startup: build a GitHub user search in React. The team code-reviews it on Day 5. Ship something a senior engineer respects.
Build this project end to end. The debounce + AbortController pattern will appear in your first code review at every company. Having already debugged the race condition is the difference between "I have heard of this" and "I fixed this before."
Extend the project: add React Query to replace the manual fetch/status state machine, add pagination, and add an integration test that mocks the GitHub API. This is the version you present in system design discussions.
Add a cache layer (SWR or React Query staleTime), rate limit handling with exponential backoff, and a server-side proxy route that adds a GitHub token. Discuss the tradeoffs of client-side vs server-side fetching for this use case.
Your Day 1 task at a startup: build a GitHub user search in React. The team code-reviews it on Day 5. Ship something a senior engineer respects.
Task assigned: GitHub user search in React, public API, deployed by Friday.
Demo works. Types a username, sees the avatar and profile. Engineer is confident.
Code review: 23 comments. Race condition on fast typing. No debounce. Tests check component state, not user behaviour. Error state shows raw JSON.
CRITICALAfter fixes: debounce added, AbortController cancels stale requests, RTL tests pass, error message is human-readable. Merged.
The question this raises
When the demo works but the code review has 23 comments, what does "production-ready" actually mean for a frontend feature?
A user types "torvalds" character by character in a search input. The component fires a fetch on every onChange event and calls setState with the response. The user sees "torval" displayed even though they finished typing "torvalds". What is the root cause?
Lesson outline
Before React and the fetch API, building a live search against an external API required XMLHttpRequest, manual DOM updates, and careful management of "which response belongs to which keypress."
How this concept changes your thinking
Fetching data on user input
“XMLHttpRequest with onreadystatechange callbacks. Nest error handling inside success handler. Manual abort via xhr.abort() if you remembered.”
“fetch() returns a Promise. AbortController cancels stale requests. async/await makes the sequence readable. React re-renders on state change automatically.”
Showing loading / error / success states
“Manually toggle CSS classes on DOM nodes. Three separate variables, easy to get out of sync. Spinner stays visible if you forget to hide it.”
“useState for status: "idle" | "loading" | "success" | "error". Single source of truth. Component re-renders to the correct UI automatically.”
Testing the component
“Hard to test DOM manipulation code. Unit tests often mocked the entire DOM. Brittle — broke on any refactor.”
“React Testing Library: render the component, type into the input, assert on what the user sees. Tests survive refactors because they test behaviour.”
The demo worked. The code had three race conditions and no error handling. Here is the exact pattern that gets flagged in every React code review:
1// The demo-works-but-fails-code-review version2function GitHubSearch() {3const [query, setQuery] = useState('');4const [user, setUser] = useState(null);56// Problem 1: fires on every keystroke — 26 requests for "torvalds"7// Problem 2: responses arrive out of order — race condition8// Problem 3: no loading state, no error state9async function handleChange(e) {No debounce: 26 requests for "torvalds", GitHub rate-limits after 60/hr unauthenticated10setQuery(e.target.value);Template literal in fetch: works but no AbortController means stale responses overwrite current state11const res = await fetch(`https://api.github.com/users/${e.target.value}`);12const data = await res.json();Race condition: if "torvalds" response arrives before "t", "t" state wins13setUser(data); // which request's data is this?14}1516return (17<div>No loading state: user sees nothing while fetch is in flight18<input onChange={handleChange} value={query} />19{/* Problem 4: shows raw API error object when user not found */}20{user && <p>{user.name || JSON.stringify(user)}</p>}21</div>22);23}
missing-abort-controller
useEffect(() => {
fetch(`/api/users/${query}`)
.then(r => r.json())
.then(setUser);
}, [query]);useEffect(() => {
if (!query.trim()) return;
const controller = new AbortController();
setStatus('loading');
fetch(`/api/users/${query}`, { signal: controller.signal })
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(data => { setUser(data); setStatus('success'); })
.catch(err => { if (err.name !== 'AbortError') setStatus('error'); });
return () => controller.abort();
}, [query]);The cleanup function returned from useEffect runs before the next effect fires, aborting the previous fetch. This eliminates the race condition. AbortError is filtered out because it is expected and not a real failure.
Two things break silently with the naive approach. The Network tab exposes both.
Network tab on fast typing (no debounce, no AbortController)
Open DevTools Network tab, filter to "Fetch/XHR", then type "torvalds" quickly. You will see: 8 requests fire in parallel. 7 are cancelled (or not — depends on timing). The responses arrive out of order: request 5 ("torval") resolves after request 8 ("torvalds"). Result: the UI shows the "torval" user, not "torvalds". Silent data corruption. GitHub rate limit header: X-RateLimit-Remaining: 0 After 60 unauthenticated requests per hour, all responses are 403. No error state = blank UI.
CORS: the error you will hit first
Calling https://api.github.com directly from localhost works because GitHub sets Access-Control-Allow-Origin: *. But if you proxy through your own backend (e.g. to add auth tokens), and your backend does not set CORS headers: "Access to fetch at 'http://localhost:3001/api/users' from origin 'http://localhost:3000' has been blocked by CORS policy" Fix: add cors() middleware to your Express server, or use a Next.js API route as the proxy.
Four patterns separate a demo from a merge-ready component:
The four patterns code reviewers look for
The code review checklist (what your senior engineer will verify)
[] Debounce: Network tab shows 1 request for "torvalds", not 8 [] AbortController: typing fast and clearing shows no stale results [] Status machine: loading spinner visible, error message is human-readable (not raw JSON) [] Empty query: no request fires for "" [] RTL test: searches for "torvalds", asserts avatar src and name appear [] Lighthouse performance: 90+ (no unoptimised avatars, lazy-load below fold)
The interviewer will open your code. These are the questions that separate candidates who copied a tutorial from candidates who understand what they built.
Questions you will be asked — strong vs weak answers
1// Production-ready version — passes code review2import { useState, useEffect, useCallback } from 'react';34type Status = 'idle' | 'loading' | 'success' | 'error';56function useDebounce<T>(value: T, delay: number): T {Custom useDebounce hook: reusable, testable, 300ms wait before firing fetch7const [debouncedValue, setDebouncedValue] = useState(value);8useEffect(() => {9const timer = setTimeout(() => setDebouncedValue(value), delay);10return () => clearTimeout(timer);11}, [value, delay]);12return debouncedValue;13}14Status state machine: one string, four states — impossible to have loading AND error simultaneously15function GitHubSearch() {16const [query, setQuery] = useState('');17const [user, setUser] = useState<GitHubUser | null>(null);18const [status, setStatus] = useState<Status>('idle');19const debouncedQuery = useDebounce(query, 300);2021useEffect(() => {22if (!debouncedQuery.trim()) { setStatus('idle'); return; }AbortController: passed as signal to fetch, cancelled in cleanup — race condition eliminated23const controller = new AbortController();24setStatus('loading');2526fetch(`https://api.github.com/users/${debouncedQuery}`, {27signal: controller.signal,28})29.then(r => r.ok ? r.json() : Promise.reject(new Error(`User not found (status ${r.status})`)))30.then(data => { setUser(data); setStatus('success'); })31.catch(err => {32if (err.name !== 'AbortError') setStatus('error');33});34AbortError filtered: expected error from cleanup, not a real failure to show the user35return () => controller.abort(); // cancel stale request on next render36}, [debouncedQuery]);3738return (39<main>40<input41aria-label="Search GitHub users"42value={query}43onChange={e => setQuery(e.target.value)}44placeholder="Type a GitHub username..."role="status" and role="alert": screen readers announce loading and error states automatically45/>46{status === 'loading' && <p role="status">Searching...</p>}47{status === 'error' && <p role="alert">User not found. Check the username.</p>}48{status === 'success' && user && (49<article aria-label={`${user.login} GitHub profile`}>50<img src={user.avatar_url} alt={`${user.login} avatar`} width="80" height="80" />51<h2>{user.name ?? user.login}</h2>52<p>{user.bio}</p>53</article>54)}55</main>56);57}
Race conditions in async React
📖 What the exam expects
A race condition occurs when two async operations run in parallel and the result depends on which finishes first. In React, this happens when a useEffect fires before the previous one completes.
Toggle between what certifications teach and what production actually requires
Live coding interviews frequently use "build a search-as-you-type" as the problem. Take-home tests often use a public API. The patterns here (debounce, AbortController, status machine, RTL) are exactly what interviewers probe for.
Common questions:
Strong answer: Mentions AbortController before being asked. Uses a status state machine instead of multiple booleans. Tests are written from the user perspective. Asks about rate limits and caching before building.
Red flags: No debounce on the input. No error state. Tests that check component state instead of user-visible output. Cannot explain what AbortController does.
Ready to see how this works in the cloud?
Switch to Career Paths for structured paths (e.g. Developer, DevOps) and provider-specific lessons.
View role-based pathsSign in to track your progress and mark lessons complete.
Questions? Discuss in the community or start a thread below.
Join DiscordSign in to start or join a thread.