Learning Log [Front-End]
Waymo TPS prep — June 2026
State
Controlled input — value + onChange are required together
React owns the value via state. Without onChange, the input is read-only.
// value in curly braces (not quotes), onChange updates state
const [text, setText] = useState("");
<input type="text" value={text} onChange={(e) => setText(e.target.value)} />for is a reserved JS word — React renames it to htmlFor. <input /> is self-closing (void element, can never have children).Two valid ways to associate a label with an input:
Wrapping (implicit) — cleaner, no id needed, good for interviews:
<label>First Name: <input value={first} onChange={...} /></label>htmlFor/id (explicit) — use when label and input are far apart in the DOM or need independent styling:
<label htmlFor="first">First Name</label> <input id="first" ... />autoFocus (camelCase in JSX, not lowercase autofocus) focuses the input on mount — handy for the first field of a form or the first box of an OTP/code input. One-line declarative alternative to a useRef + useEffect focus call.Every setState call triggers a re-render
React re-runs the entire component function top to bottom on every render. Plain variables and functions defined inside the component are recreated each time. For values that should persist without triggering re-renders, use useRef.
// plain let resets to 0 on every render — useless as a counter let counter = 0; // ✗ // useRef persists across renders without triggering one const idRef = useRef(0); // ✓ read with idRef.current idRef.current++; // increment — no re-render // other options for unique IDs id: Date.now() // fine for click-driven demos id: crypto.randomUUID() // cleanest, no setup
Never call setState in the render body — infinite loop
Calling a set function directly during render schedules a re-render, which re-runs the body, which calls set again, forever. State updates belong in event handlers, in useEffect, or in the lazy initializer of useState when it's just seeding initial state.
// ✗ runs on every render → schedules render → infinite loop function FileTree({ input_list }) { const [toggles, setToggles] = useState(new Map()); for (const item of input_list) setToggles(...); // ✗ in render body // ✓ seed once — lazy initializer, runs only on first mount const [toggles, setToggles] = useState(() => collectFolderIds(input_list)); // ✓ or derive from changing props/state inside an effect useEffect(() => { setToggles(collectFolderIds(input_list)); }, [input_list]); }
setToggleSettings in the render body → infinite re-render, should have used the lazy initializer or a useEffect; (2) didn't reach for recursion to collect all folder IDs until too late — a tree or "get all X in the structure" is the recursion/BFS tell, decide traversal before writing render code. Articulated the full fix and the interviewer agreed, but didn't finish in time.State is stale until the next render — compute first, set state at the end
After calling setBoard(alteredBoard), reading board still returns the old value. Derive everything you need from local variables first, then commit all state updates at the end.
// ✓ derive from local variables, set state at the end function updateCell(row, col) { const newValue = isXTurn ? "X" : "O"; const alteredBoard = board.map((r, rIdx) => r.map((cell, cIdx) => rIdx === row && cIdx === col ? newValue : cell) ); const hasWinner = gameHasWinner(alteredBoard, newValue); // use alteredBoard, not board setBoard(alteredBoard); // board is still old here setIsXTurn(!isXTurn); if (hasWinner) setWinner(isXTurn ? "Player X" : "Player O"); } // ✗ board still shows old value after setBoard setBoard(alteredBoard); console.log(board); // old board
Structure a component: state → derived → handlers → JSX
Always declare in this order. It matches React's data flow and makes components easy to scan. Interviewers notice structure — brief section comments are worth adding to show intent.
export default function App() {
// state — only what can't be computed
const [users, setUsers] = useState([...]);
const [selectedUserId, setSelectedUserId] = useState(null);
const [firstName, setFirstName] = useState("");
const [searchQuery, setSearchQuery] = useState("");
// hooks + derived values (useMemo/useCallback go here, right after state)
const filteredUsers = useMemo(() =>
users.filter(u => fullName(u).includes(searchQuery)), [users, searchQuery]);
const debouncedSearch = useCallback(debounce(setSearchQuery, 300), []);
const canCreate = !selectedUserId && firstName.trim(); // cheap — no useMemo needed
// handlers
const handleCreate = () => { ... };
const handleDelete = () => { ... };
// JSX
return ( ... );
}filteredUsers as state means manually keeping it in sync with users and searchQuery. Use useMemo instead and let React recompute it.board, isXTurn, isComputerThinking. Everything else (winner, draw, isGameOver) follows from the board.React batches multiple state updates into one re-render
Multiple set calls in the same event handler don't each trigger a re-render. React waits until the function finishes, then re-renders once with all updates applied. This is automatic in React 18+ everywhere (including setTimeout and promises).
setBoard(alteredBoard); // doesn't re-render yet setIsXTurn(!isXTurn); // doesn't re-render yet setWinner(playerName); // doesn't re-render yet // → ONE re-render here with all updates applied
Functional setState — setX(prev => ...) when new state depends on old
Passing a function gives you React's latest committed state as prev, instead of the value captured in this render's closure. Identical for a single update, but the closure value goes stale when updates batch or fire in quick succession.
// ✓ reads the latest state — safe when updates stack up setDigits(prev => prev.map((d, i) => (i === index ? char : d))); // ✗ reads `digits` from the closure — a snapshot from this render setDigits(digits.map((d, i) => (i === index ? char : d))); // classic proof: this only adds 1 — both read the same stale count setCount(count + 1); setCount(count + 1); // functional form adds 2 — each call gets the previous result setCount(c => c + 1); setCount(c => c + 1);
prev => whenever the next state is derived from the previous state (toggles, counters, mapping a list). Costs nothing and kills a whole class of stale-closure bugs — e.g. paste-then-focus or fast typing in an OTP input.Render → Commit → Paint → Effect — when things actually run
React runs your component function top to bottom (render), but does NOT run useEffect inline — it just registers the callback. Only after the DOM is updated does the effect run. That's why effects can safely touch the DOM and refs: by the time they run, the elements exist.
// 1. RENDER — function runs top to bottom, builds JSX. // useEffect(fn, []) is RECORDED, not called. DOM not created yet. // 2. COMMIT — React applies JSX to the real DOM; ref callbacks fire, // filling inputsRef.current. // 3. PAINT — browser draws the updated DOM to the screen. // 4. EFFECT — React runs the useEffect callback. Refs are populated now. useEffect(() => { inputsRef.current[0]?.focus(); // works — the input exists by now }, []); // [] = run once, after the first commit
useLayoutEffect is the sibling that runs after DOM mutation but BEFORE paint — use it to avoid a visible flicker when measuring/repositioning; plain useEffect is right for focus.useReducer — one pure function owns all state transitions
Same power as useState, but the update logic lives in one pure reducer(state, action) function instead of scattered set calls. The component just dispatches an action describing what happened — the reducer decides the next state. Reach for it when the next state depends on the previous one, there are several distinct actions, or you want the logic testable in isolation. A single toggle doesn't need it — say so if asked.
// simplest possible example — a counter function reducer(count, action) { switch (action.type) { case "INC": return count + 1; case "DEC": return count - 1; case "RESET": return 0; default: return count; // unknown action → no change } } const [count, dispatch] = useReducer(reducer, 0); // 2 args: reducer, initial state <button onClick={() => dispatch({ type: "INC" })}>+</button> // fire an action <button onClick={() => dispatch({ type: "RESET" })}>reset</button>
dispatch(action) → React runs reducer(state, action) → new state → re-render. The child stays dumb, all rules live in the reducer.type. dispatch({ type: "CYCLE_CELL", r, c }), not dispatch("CYCLE_CELL"). That object IS the action the reducer gets as its 2nd arg — put whatever payload the reducer needs right on it (r, c, ids, values) and read them back with const { r, c } = action. Convention is { type, ...payload }.cells[r][c] = x; return cells returns the same reference so React may skip the re-render). No API calls, no Math.random, no side effects inside it — those belong in handlers or effects.// 2 args — YOU call the function, React uses the returned value as initial state useReducer(reducer, makeInitial()); // runs every render, result only used on the 1st // 3 args (lazy) — you pass the function ITSELF (no parens); React calls it once on mount useReducer(reducer, undefined, makeInitial); // React runs makeInitial(undefined) // ↑ 2nd arg is the input to makeInitial; unused here, so undefined
()) and React calls it once on mount, lazily building the initial state instead of rebuilding it every render. The tell: 2-arg form you write makeInitial() (you call it), lazy form you write makeInitial (React calls it). For cheap state the 2-arg form is fine — reach for lazy init only when building initial state is expensive.Components
Rules of Hooks — only call hooks at the top level of a function component
Hooks can't be called inside regular functions, callbacks, or loops. A helper function called inside .map() is not a component — extract it as a proper component (capital letter name) to use hooks inside it.
// ✗ renderCell is a regular function — useState here causes a React error const renderCell = (value) => { const [isHovered, setIsHovered] = useState(false); // INVALID }; // ✓ CellButton is a proper component — hooks are allowed const CellButton = ({ value, onClick }) => { const [isHovered, setIsHovered] = useState(false); // valid return <button onClick={onClick}>{value}</button>; };
useState, useCallback, useMemo, etc.) at the top of the component, before any derived values or handlers. This makes the hook order consistent across renders (required by React) and keeps the component easy to scan.Props are a single object — destructure with {'{}'}
// ✗ wrong — value is the whole props object, rest are undefined const CellButton = (value, hoveredValue, onClick) => { ... } // ✓ destructure the single props object const CellButton = ({ value, hoveredValue, onClick }) => { return <button onClick={onClick}>{'{'}value{'}'}</button>; };
Render arrays with .map() — every item needs a key
.map() returns an array of JSX elements, which React renders. forEach returns undefined — nothing renders. The key must be a stable unique id, not the array index.
<ul>
{tasks.map((task) => (
<li key={task.id}>
<span>{task.summary}</span>
<button onClick={() => removeTask(task.id)}>Delete</button>
</li>
))}
</ul>=> {'{ ... }'} is a function body — needs explicit return. Arrow with parens => ( ... ) is an implicit return. Blank list = missing return.Wrap inputs in a <form> — Enter key submits for free
<form onSubmit={(e) => {
e.preventDefault(); // required — prevents full page reload
handleSubmit();
}}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit">Submit</button>
</form>type="submit" on the button triggers onSubmit; type="button" does not.e.preventDefault() first. Without it the browser reloads the page on submit, blowing away all React state. Make it the first line of every onSubmit handler — forgetting it in an interview is immediately visible.Single form with button intents — one onSubmit handler for all actions
Instead of separate onClick handlers per button, wrap everything in one <form> and use name="intent" on each button to identify which action was triggered. event.nativeEvent.submitter is the specific button clicked — passing it to FormData includes it in the form data.
// buttons declare their intent via name + value <form onSubmit={onSubmit}> <button name="intent" value="create" disabled={!canCreate}>Create</button> <button name="intent" value="update" disabled={!canUpdate}>Update</button> <button name="intent" value="delete" disabled={!hasSelected}>Delete</button> </form> function onSubmit(e) { e.preventDefault(); // submitter = the button that was clicked const formData = new FormData(e.target, e.nativeEvent.submitter); const intent = formData.get('intent'); // "create" | "update" | "delete" switch (intent) { case 'create': create(); break; case 'update': update(); break; case 'delete': del(); break; default: throw new Error(`Invalid intent: ${intent}`); } }
canCreate and canUpdate are mutually exclusive (can't have both true at once), Enter always hits the right action without extra logic.Single form + intents
- One handler, less repetition
- Enter key works correctly for free
Separate onClick handlers
- More explicit, easier to follow
- No switch/intent pattern to explain
Modal confirmation pattern
window.confirm is instant but unstyled. A custom modal uses state to track what's pending deletion. Two divs: overlay for dimming + click-outside, modal for the box.
// 1. window.confirm — zero code, ugly if (window.confirm("Delete?")) setTasks(tasks.filter(t => t.id !== id)); // 2. custom modal — store pending action in state const [pendingDeleteId, setPendingDeleteId] = useState(null); {pendingDeleteId !== null && ( <div className="modal-overlay" onClick={() => setPendingDeleteId(null)}> <div className="modal" onClick={(e) => e.stopPropagation()}> <p>Delete this task?</p> <button onClick={() => { setTasks(tasks.filter(t => t.id !== pendingDeleteId)); setPendingDeleteId(null); }}>Confirm</button> <button onClick={() => setPendingDeleteId(null)}>Cancel</button> </div> </div> )}
e.stopPropagation() on the inner modal prevents clicks from bubbling up to the overlay and closing it accidentally.<select size={'{n}'}> renders a listbox — children must be <option>
Without size, <select> is a dropdown. Setting size={'{5}'} makes it a visible listbox showing 5 rows. Only one item can be selected by default (add multiple for multi-select).
// controlled listbox — value must match an option's value <select size={5} value={selectedId ?? ""} onChange={(e) => setSelectedId(e.target.value)} > <option value="">-- select a user --</option> // placeholder required (see gotcha) {users.map(user => ( <option key={user.id} value={user.id}> // must be option, not div {user.first} {user.last} </option> ))} </select>
selectedId becomes null, value="" matches nothing in the DOM. The browser must always track one item as "active" — it's not optional — so it falls back to highlighting the first option internally. That's the DOM's own tracking, not React's state. Clicking the first item then looks like "nothing changed" to the browser, so onChange never fires.Fix: add a
<option value=""> placeholder. Now value="" matches a real option, React fully owns the selection, and clicking any user is a genuine change from "" to a UUID.useMemo vs useCallback — memoize values and functions
useMemo caches the result of a computation. useCallback caches a function reference. Both take a dependency array — recompute only when deps change.
// useMemo — memoize a derived value (expensive compute, filtered list, etc.) const filteredUsers = useMemo( () => users.filter(u => u.name.includes(query)), [users, query] // recompute when users or query changes ); // useCallback — memoize a function reference (debounce, stable callback for child props) const debouncedSearch = useCallback( debounce((val) => setSearchQuery(val), 300), [] // [] = create once on mount, never recreate );
useMemo
- Returns a cached value
- Use for expensive calcs, filtered/sorted arrays, derived objects
useCallback
- Returns a cached function
- Use when memoizing a function (debounce, stable prop callbacks)
useMemo(() => fn, []) and useCallback(fn, []) are equivalent — but useCallback signals intent more clearly when the result is a function. The () => wrapper in useMemo is always required — it's what useMemo calls to get the value. Without [], the debounced function recreates every render and the timer resets on every keystroke.React.memo — skip re-rendering a child when its props haven't changed
By default a child re-renders whenever its parent does, even if its props are identical. Wrapping the component in memo makes React shallow-compare props and reuse the last render when they all match. Big win for many small children — clicking one cell of a 100-cell board re-renders only that cell, not all 100.
const Cell = memo(function Cell({ r, c, state, onClick }) {
return <button onClick={() => onClick(r, c)}>...</button>;
});=== (shallow). Primitives (state, r) compare by value and pass. Functions and objects compare by REFERENCE — a fresh one each render fails the check and defeats memo. So any function passed as a prop must be wrapped in useCallback (and objects/arrays in useMemo), or memo does nothing.const onClick = useCallback((r, c) => dispatch({ type: "CYCLE_CELL", r, c }), []);useMemo caches a value, useCallback caches a function reference, memo caches a component's render. The first two are hooks called inside a component; memo wraps a component from the outside. Pair memo on the child with useCallback for its function props.Array of refs — one useRef([]) to focus many elements
For a list of inputs (OTP boxes, a grid) hold all DOM nodes in one ref whose .current is an array. Assign each node in the ref callback — el is the actual DOM element React hands you as the input mounts.
const inputsRef = useRef([]); // .current is an array, NOT null <input ref={(el) => { inputsRef.current[index] = el; }} // el = the DOM node ... /> inputsRef.current[0]?.focus(); // focus first box inputsRef.current[index + 1]?.focus(); // focus the next
[], not null — null[2] = el throws, but [][2] = el just grows the array (indices 0–1 become empty holes). And out-of-bounds reads return undefined, so inputsRef.current[-1]?.focus() is a safe no-op — ?. means no bounds check needed.Language Fundamentals
Plain object {'{}'} vs Map
Plain object
- Simpler syntax, less to type
- Fine for string keys (chars, words)
- Bracket notation:
obj[key]
Map
- Keys can be any type
- Numbers stay numbers (no coercion)
.sizebuilt in- Must use
.get(key)/.set(key, val)
// plain object coerces keys to strings obj[1] === obj["1"]; // true — same key // Map preserves types mp.set(1, "a"); mp.get(1) !== mp.get("1"); // true — different keys
{'{}'} for string keys. Reach for Map when keys are numbers, objects, or when building adjacency lists.Object {'{}'} vs array [] destructuring
// object — matches by NAME const { value, hoveredValue } = props; // array — matches by POSITION, name it whatever you want const [board, setBoard] = useState(...); const [isXTurn, setIsXTurn] = useState(...);
Computed property names — [expr] as an object key
Square brackets around a key make JS evaluate what's inside and use the result as the key, instead of the literal text. Ties the key to a constant or expression so it can't drift out of sync with the values you store and look up.
const EMPTY = "EMPTY";
const a = { EMPTY: 1 }; // key is the literal string "EMPTY"
const b = { [EMPTY]: 1 }; // key is the VALUE of EMPTY → also "EMPTY"
// same today, but change the constant's value and only b follows:
const EMPTY2 = "empty";
({ EMPTY: 1 }); // key still "EMPTY" — now out of sync
({ [EMPTY2]: 1 }); // key "empty" — tracks the constant// state → { color, next } config keyed by computed names const EMPTY = "EMPTY", HIT = "HIT", MISS = "MISS"; const STATE_CONFIG = { [EMPTY]: { color: "gray", next: HIT }, [HIT]: { color: "green", next: MISS }, [MISS]: { color: "red", next: EMPTY }, }; // cells store the state itself; config derives color + next from it STATE_CONFIG[cell].next; // next state on click STATE_CONFIG[cell].color; // color to paint
value: "HIT" field that just repeats it (it drifts). Color and next state are derived, so they belong in the config.{ [`${prefix}_1`]: true } → key "ship_1". Without brackets, { prefix: 1 } would use the literal word "prefix".for...of iterates values — avoid for...in on arrays
for (const x of arr) console.log(x); // 10, 20, 30 ✓ for (const i in arr) console.log(i); // "0", "1", "2" — string keys ✗ // plain objects — use Object.entries for (const [key, val] of Object.entries(obj)) { ... } // Map — for...of directly for (const [key, val] of myMap) { ... }
localeCompare — alphabetical string sorting
Use a.localeCompare(b) inside .sort() for alphabetical order. Returns negative, zero, or positive — same contract as any sort comparator.
// sort strings alphabetically arr.sort((a, b) => a.localeCompare(b)); // sort objects by name items.sort((a, b) => a.name.localeCompare(b.name)); // combine with category sort — directories before files, then alpha files.sort((a, b) => { const aIsDir = !!a.children; const bIsDir = !!b.children; if (aIsDir !== bIsDir) return aIsDir ? -1 : 1; return a.name.localeCompare(b.name); });
a > b ? 1 : -1 for strings — it's less reliable across locales and special characters. localeCompare handles accents, case, and language rules correctly.Test a character with a regex — /\d/.test(ch)
regex.test(str) returns a boolean. Works on a single char or a whole string. There's no char type in JS — a single character is just a string of length 1, so the same test runs on "5" or s[0].
/\d/.test(ch); // true if ch is a digit 0-9 (\d === [0-9]) /[0-9]/.test(ch); // same, written explicitly /^[0-9]$/.test(ch); // true only if EXACTLY one digit (anchored) /^\d+$/.test(str); // true if the WHOLE string is all digits
/^[0-9]$/) over rejecting only single bad chars — a guard like ch.length === 1 && !/\d/.test(ch) lets multi-char key names ("ArrowLeft", "Tab") slip through. Avoid !isNaN(ch) for digit checks: isNaN("") is false because "" coerces to 0.TypeScript enums & Object.freeze
// TS enum enum InputType { CELSIUS = "celsius", FAHRENHEIT = "fahrenheit" } // JS equivalent — Object.freeze prevents mutation const InputType = Object.freeze({ CELSIUS: "celsius", FAHRENHEIT: "fahrenheit" }); // TS function signature function convert(value: string, from: InputType): number { ... }
Functional Programming
map / filter / reduce — signatures and chaining
map transforms every element (same length). filter keeps elements where callback returns true (shorter). reduce collapses to one value — number, object, Map, anything. Chain filter → sort → map for fleet-style queries.
// map — must return transformed value vehicles.map(v => v.id); // filter — must return boolean vehicles.filter(v => v.stationarySeconds > 300); // reduce — must return accumulator. Forgetting return → undefined next step const freq = arr.reduce((acc, item) => { acc[item] = (acc[item] ?? 0) + 1; return acc; // always return acc }, {}); const byId = vehicles.reduce((acc, v) => { acc.set(v.id, v); return acc; }, new Map()); // chaining — filter first (smaller array), sort, then map const result = vehicles .filter(v => v.stationarySeconds > 300) .sort((a, b) => b.stationarySeconds - a.stationarySeconds) .map(v => v.id); // _ convention — intentionally unused parameter arr.map((_, index) => index * 2);
sort & reverse mutate — slice() first
.filter() and .map() never touch the original and return a new array. .sort() and .reverse() reorder in place and return the SAME array. So sorting an array you don't own silently scrambles the original.
// ✗ sorts the source constant itself — reorders it for every other reader, // and the damage compounds on each render const visible = VEHICLES.sort((a, b) => b.stoppedSeconds - a.stoppedSeconds); // ✓ slice() makes a throwaway copy — sort chews on the copy, source is safe const visible = VEHICLES.slice().sort((a, b) => b.stoppedSeconds - a.stoppedSeconds); // ✓ modern: toSorted() returns a NEW sorted array, never mutates (no slice needed) const visible = VEHICLES.toSorted((a, b) => b.stoppedSeconds - a.stoppedSeconds);
// pipeline order is left-to-right — each method runs fully, hands result to next const visible = useMemo(() => VEHICLES .filter(v => statusFilter === 'all' || v.status === statusFilter) // new array .filter(v => v.id.toLowerCase().includes(query.toLowerCase())) // new array .slice() // copy (see note) .sort((a, b) => b.stoppedSeconds - a.stoppedSeconds), // mutates the copy [statusFilter, query]);
slice() is redundant — the .filter() before it already produced a fresh array, so .sort() is mutating a copy, not VEHICLES. Keep it anyway as cheap insurance: if someone later deletes the filters, .sort() would fall through onto VEHICLES itself. Filter before sort is also faster — fewer items to sort..sort() or .reverse() an array you didn't just create, .slice() it first (or use .toSorted() / .toReversed()). When the previous step is already a .filter()/.map(), the copy is free but leaving slice() in is harmless.setItems(items.sort(...)) can bail out of re-rendering entirely and the screen never updates. The copy isn't just hygiene, it's what makes the render happen. Better still, don't put sorted data in state at all — derive it during render from the source array plus a sortKey, so there's only one source of truth and nothing to keep in sync.[10, 9, 1].sort() gives [1, 10, 9] because it stringifies first. Always pass a comparator for numbers. For strings, localeCompare beats a < b once accents or casing are involved.toSorted / toReversed / toSpliced / with are ES2023. Fine in current browsers and Node 20+, but a shared coding sandbox may be on an older runtime. In an interview reach for [...arr].sort() and mention toSorted() as the modern equivalent — you get the knowledge credit without risking a not a function error on someone else's machine.flatMap — map + flat(1) in one step
Return [item] to include, [] to skip. Useful for filter+map on nested arrays in one pass.
// get all empty cells from a 2D board const availableCells = board.flatMap((row, rowIdx) => row.flatMap((cell, colIdx) => cell === "_" ? [{ row: rowIdx, col: colIdx }] : [] ) ); // simpler example [1, 2, 3].flatMap(x => [x, x * 2]); // [1,2, 2,4, 3,6]
.filter().map() is more readable. Reach for flatMap when working with already-nested arrays.Async & Timing
Event loop — sync → microtasks → event queue
JS is single-threaded. Promises (microtasks) drain before setTimeout callbacks (event queue), even with 0ms delay.
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('sync');
// sync ← call stack runs first
// promise ← microtask queue drains before event queue
// timeout ← last, even with 0ms delaysetTimeout is async — follow-up code goes inside the callback
// ✗ setIsThinking runs immediately, not after delay setTimeout(delay); setIsThinking(false); // ✓ everything after the delay goes inside the callback timeoutId.current = setTimeout(() => { setIsThinking(false); updateCell(row, col); }, delay); // store id in useRef to cancel on reset const timeoutId = useRef(null); clearTimeout(timeoutId.current); // in resetGame()
requestAnimationFrame — the primitive for smooth, time-driven UI
Fires right before the next repaint, synced to the display's real refresh rate. Auto-pauses in background tabs, and degrades gracefully under load instead of queueing backlogged calls. Compute position as a pure function of elapsed time, don't increment a counter per tick — that's what makes it robust to dropped frames and backgrounding.
// progress bar filling over a fixed duration const frameIdRef = useRef(null); useEffect(() => { const startTime = performance.now(); const step = (now) => { const elapsed = now - startTime; // pure function of time, not a counter setPercent(Math.min((elapsed / DURATION) * 100, 100)); if (elapsed < DURATION) frameIdRef.current = requestAnimationFrame(step); }; frameIdRef.current = requestAnimationFrame(step); return () => cancelAnimationFrame(frameIdRef.current); // only fires on unmount — [] means the effect itself never re-runs }, []);
performance.now() and the timestamp rAF hands its callback are the same clock, monotonic milliseconds from performance.timeOrigin, not wall-clock time. Never use Date.now() for animation math — it can jump if the system clock adjusts. All rAF callbacks scheduled for the same frame get the identical timestamp, so simultaneous animations stay in sync — prefer the argument over calling performance.now() yourself.setInterval isn't wrong, it's just the right tool for a different job. It's not synced to the browser's paint cycle (wasted or missed updates), and it keeps firing in background tabs (throttled, not paused), so a naive per-tick counter can jump when the tab regains focus. Use it for coarse, periodic updates that don't need smooth motion — a countdown timer updating once a second. Use rAF for continuous visual motion — a progress bar, a smooth interpolation between two points.Serializing siblings — a ready prop + index gate (Progress Bars II)
Each sibling only starts its own rAF loop once a shared readyIndex reaches its own index, ready={'{index <= readyIndex}'}, and the effect is gated with if (!ready) return; before starting the loop. When a bar finishes it reports its own index up via onFinish, the parent advances readyIndex by exactly one, which flips the next sibling's ready from false to true and its effect (dep [ready]) fires for the first time.
onFinish={() => onFinishHandler(index)}
const onFinishHandler = (completedIndex) =>
setReadyIndex((prev) => Math.max(prev, completedIndex + 1));Math.max(prev, completedIndex + 1) is defensive but not strictly required here — a sibling can only finish after the previous one already has, so completions only ever arrive in increasing order. Worth knowing why it's redundant in case it's questioned, safe to leave in either way.index <= readyIndex, not ===. readyIndex is a monotonic high-water mark — once a bar is unlocked it must stay unlocked. === would flip every already-finished bar's ready back to false the moment the frontier passes it, only masked here because percent is separate local state that survives the flip.useEffect cleanup — return a function, don't call it inline
Most effects don't need cleanup. But when you set up a timer, subscription, or event listener, React needs to know how to tear it down. Return a function — React will call it when the dependency changes or the component unmounts.
// ✗ Wrong — clearTimeout runs immediately, cancelling the timer you just set useEffect(() => { const timer = setTimeout(..., 1000); clearTimeout(timer); // fires right now, not on cleanup }, [currentColor]); // ✓ Correct — return a function; React calls it later at cleanup time useEffect(() => { const timer = setTimeout(() => { setCurrentColor(getNextColor()); }, TRAFFIC_LIGHT_CONFIG[currentColor].duration); return () => clearTimeout(timer); // React runs this when currentColor changes or component unmounts }, [currentColor]);
{show && <TrafficLight />} flips false), navigating to a different route, or the parent stops rendering it. You don't detect this yourself; returning a cleanup function is enough.Debounce vs throttle
Debounce fires only after N ms of silence — every new call resets the timer. Throttle fires immediately then ignores calls until the interval passes.
// debounce — wait until user stops typing
const debouncedSearch = debounce((query) => fetchResults(query), 300);
input.addEventListener('input', (e) => debouncedSearch(e.target.value));Use debounce when
- Search / autocomplete inputs
- Form field validation
- Window resize
Use throttle when
- Scroll position tracking
- Mouse move / drag
- Rate-limiting button clicks
debounce in useCallback (or useMemo) with []. Without it, a new debounced function is created on every render — each one has its own fresh timer, so the delay never fires correctly.const debouncedSearch = useCallback(debounce((val) => fetchResults(val), 300), []);// lodash debounce + useMemo — the interview default for a search input import debounce from "lodash/debounce"; const [query, setQuery] = useState(""); // useMemo memoizes the debounced FUNCTION (a value) so it's stable across renders. // The timer id lives inside debounce's own closure — no manual useRef needed. const debouncedSetQuery = useMemo( () => debounce((value) => setQuery(value), 300), [] // [] = create once. setQuery from useState is stable, so no deps ); // cancel any pending call when the component unmounts useEffect(() => () => debouncedSetQuery.cancel(), [debouncedSetQuery]); <input onChange={(e) => debouncedSetQuery(e.target.value)} />
useMemo stores the debounced function (a stable value — exactly what useMemo is for). A hand-rolled debounce calls setTimeout/clearTimeout yourself, so you need useRef to hold the timer id for cancellation. With lodash.debounce the timer id is encapsulated inside the returned function's closure — you didn't eliminate the ref, you hid it inside the helper. Never run the timer logic inside useMemo — that's a side effect during render.setState on an unmounted component, which warns. Return () => debouncedSetQuery.cancel() from a useEffect. This is the detail interviewers look for. Also: keep the input's displayed value instant (local/uncontrolled) and only debounce the query that drives filtering — binding the input directly to the debounced value feels laggy.useMemo is a performance hint, not a guarantee — React may drop the cached value and rebuild the debounced fn, resetting its pending timer. In practice this rarely bites and the lodash + useMemo + cleanup pattern is widely accepted. If pressed, the airtight version is useRef lazy init, or a useEffect that creates the debounced fn and returns .cancel as cleanup.Fetching from an API — the shape to memorize
Two-step every time. fetch(url) returns a promise for a Response object, not the data. await it, then call .json() which is itself async (it reads and parses the body stream), so it needs a second await. That's why the one-liner uses .then(r => r.json()).
// long form — two awaits, the Response then the parsed body const res = await fetch(url); // Response object (headers, status) const data = await res.json(); // .json() is async → parses body to JS object/array // one-liner — same thing, chained const data = await fetch(url).then((r) => r.json());
await only works inside an async function. A React component body is not async, so you can't await at the top level of the component. Put the fetch inside an async function declared inside a useEffect (or a click handler), then call it. The effect callback itself must stay synchronous — never write useEffect(async () => ...).useEffect(() => {
async function load() { // async fn lives inside the effect
setIsLoading(true);
const ids = await fetch(JOB_STORIES_URL).then((r) => r.json());
// ...use ids...
setIsLoading(false);
}
load(); // invoke it; effect returns nothing (or a cleanup fn)
}, []); // [] = run once on mountawait runs top to bottom in order — each line waits for its promise before the next runs. But await only pauses that function, not the whole app. React keeps rendering and other events keep firing while it waits. Always wrap fetches with an isLoading flag and (in real code) a try/catch for errors.Promise.all — fire N requests in parallel, wait for all
When you have a list of IDs and need to fetch each one's details, don't await inside a loop — that runs them one after another (slow). Map the IDs to an array of promises and hand it to Promise.all, which fires them all at once and resolves when the last one finishes.
// ✗ sequential — each await blocks the next, 6 round-trips in series const jobs = []; for (const id of pageIds) { jobs.push(await fetch(itemUrl(id)).then((r) => r.json())); // slow } // ✓ parallel — all 6 fire at once, one await for the whole batch const jobs = await Promise.all( pageIds.map((id) => fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`).then((r) => r.json()) ) );
Promise.all returns results positionally — jobs[0] matches pageIds[0] regardless of which response arrived first. Common interview follow-up: "do the results stay in order?" Yes.Promise.all rejects as soon as any promise rejects (all-or-nothing). If you need the successful ones even when some fail, use Promise.allSettled, which returns a status + value/reason for each.Promise.all / allSettled / race / any — pick by failure behaviour
Four combinators, and the only thing that distinguishes them is what makes them settle and what makes them reject. Learn that column and the choice is automatic.
// all — need EVERYTHING. rejects on the FIRST failure (fail fast) const [a, b] = await Promise.all([p1, p2]); // allSettled — want every outcome. NEVER rejects const rs = await Promise.allSettled([p1, p2]); // [{status:'fulfilled', value}, {status:'rejected', reason}] const ok = rs.filter(r => r.status === 'fulfilled').map(r => r.value); // race — FIRST to settle wins, resolve OR reject. timeouts. await Promise.race([fetchIt(), rejectAfter(5000)]); // any — first SUCCESS wins. rejects only if ALL fail (AggregateError) await Promise.any([mirror1(), mirror2(), mirror3()]);
all. Need all results even if some fail → allSettled. Need the fastest, and a failure counts as an answer → race. Need the fastest success, failures ignored → any. The race vs any distinction is the one interviewers probe: race settles on the first rejection too, so a fast-failing request beats a slow-succeeding one.Promise.race resolving does not stop the other fetches — they run to completion, burn bandwidth, and their .then still fires. A promise is a notification of a result, not a handle on the work. That's exactly the gap AbortController fills, and it's why "timeout with Promise.race" is only half an answer.AbortController — the handle a Promise doesn't give you
A controller owns a signal. Pass the signal into fetch, call .abort(), and the in-flight request is genuinely cancelled and its promise rejects.
const c = new AbortController(); fetch(url, { signal: c.signal }) .then(r => r.json()) .catch(err => { // MUST distinguish: your own cancel vs a real failure if (err.name === 'AbortError') return; // expected setError(err); // real }); c.abort(); // ONE controller cancels MANY requests — share the signal urls.forEach(u => fetch(u, { signal: c.signal })); c.abort(); // all of them die // built-in timeout, no manual setTimeout fetch(url, { signal: AbortSignal.timeout(5000) }); // → TimeoutError // compose several reasons to stop fetch(url, { signal: AbortSignal.any([userCancel.signal, AbortSignal.timeout(5000)]) }); // React cleanup — cancel on unmount / dep change useEffect(() => { const c = new AbortController(); fetch(url, { signal: c.signal })... return () => c.abort(); }, [url]);
AbortError check is not optional. Skip it and your own cleanup renders an error state — unmount the component and the user sees "Something went wrong" flash on the way out. Every .catch on an abortable fetch needs that guard as its first line.addEventListener(type, fn, { signal }) also accepts a signal, so one abort() removes every listener you registered with it. Replaces a pile of removeEventListener calls in cleanup. Same "one switch kills all the subscriptions" idea as sharing a signal across fetches.Timeouts — total vs idle, and why the difference broke the Waymo round
Two different clocks that both get called "a 60 second timeout," and picking the wrong one silently changes the behaviour.
// TOTAL timeout — one clock, started once, never reset. // "give up 60s after we began, no matter what" const withTimeout = (p, ms) => Promise.race([ p, new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms)) ]); // IDLE timeout — clock RESETS on every arrival. // "give up after 60s of NOTHING happening" let timer; const arm = () => { clearTimeout(timer); // reset — this is the whole trick timer = setTimeout(finish, 60_000); }; arm(); // start the clock onEachResponse(() => arm()); // progress → restart it
clearTimeout in .finally. A timer that fires after you've already settled will call setState on an unmounted component or overwrite a good result with a timeout error. And in React the timer id goes in a ref, never state — it's bookkeeping, and putting it in state re-renders on every arm/disarm. Same ref-vs-state split as the countdown timer.Racing a best-quality fetch against a fallback timeout — flagged gap
Waymo round, Jul 28. Given an array of URLs ordered best → worst quality: fetch the best one; if a better response doesn't land within 60s, render the current best but keep listening in case one still arrives; if nothing new arrives for 60s at any point, stop and render the best data (or an error if there's none). Went in shaky — .then/.catch/.finally, AbortController, and timer-driven waiting aren't solid yet. [TODO] Review before the next round.
setTimeout, or Promise.race). .then handles a response that arrives in time, .catch handles a failed/aborted request, .finally is where you'd clear the pending timer either way so it can't fire after you've already moved on. AbortController is for cancelling an in-flight fetch you've decided you no longer need — not just cleanup, it's the same "stop this in-flight thing" idea as cancelAnimationFrame in the countdown timer, applied to a network request instead of a frame.Clarify first — the prompt has two readings
Parallel (most likely): fire all URLs at once, track the best-quality response received so far, stop when nothing new has arrived for 60s. Sequential: try best, on failure try the next one down. The phrase "wait 60s for a better response to arrive" implies other requests are already in flight, so parallel. Ask. Getting this wrong wastes the whole round, and asking costs fifteen seconds.
// urls ordered best → worst. Lower index = higher quality. function fetchBestQuality(urls, { idleMs = 60_000 } = {}) { return new Promise((resolve, reject) => { const controller = new AbortController(); let bestIdx = Infinity; // lowest index seen so far let bestData = null; let pending = urls.length; let timer; const finish = () => { clearTimeout(timer); controller.abort(); // kill everything still in flight bestData !== null ? resolve(bestData) : reject(new Error('all sources failed')); }; const arm = () => { // IDLE timer — reset on progress clearTimeout(timer); timer = setTimeout(finish, idleMs); }; urls.forEach((url, i) => { fetch(url, { signal: controller.signal }) .then(res => { // fetch does NOT throw on 404 — check it yourself if (!res.ok) throw new Error(res.status); return res.json(); }) .then(data => { if (i < bestIdx) { bestIdx = i; bestData = data; } if (bestIdx === 0) return finish(); // can't beat best arm(); // progress → reset }) .catch(() => { /* this source is out; others may land */ }) .finally(() => { if (--pending === 0) finish(); // nothing left }); }); arm(); // start the clock }); }
· Idle vs total timeout —
arm() calls clearTimeout before re-setting. That reset is the answer to "if nothing new arrives for 60s."
· Early exit —
bestIdx === 0 means the best possible source already landed, so waiting is pure latency. Easy to miss and cheap to add.
·
.catch per request, not on the whole batch — one dead URL must not kill the others. This is why it isn't Promise.all.
·
.finally for the pending counter — decrements on success and failure, so you settle as soon as nothing is in flight rather than always waiting out the full 60s.finish() more than once is safe — resolve is idempotent, so the aborted fetches hitting .catch → .finally → finish() again are harmless no-ops. Volunteering that shows you traced the abort path. (2) I reset the idle timer only on success, not on failure. A URL that fails instantly shouldn't extend your patience — but it's a judgement call worth naming as an assumption.The React wrapper
useEffect(() => {
let alive = true;
const c = new AbortController();
fetchBestQuality(urls, { signal: c.signal })
.then(d => { if (alive) setData(d); })
.catch(e => {
if (e.name === 'AbortError') return; // our own cleanup
if (alive) setError(e);
});
return () => { alive = false; c.abort(); };
}, [urls]);bestData is real state — it renders. The timer id and the AbortController are bookkeeping and belong in refs, because arming a timer must not trigger a re-render. Same split as the countdown timer. In the plain-function version above they're just closure variables, which is cleaner still — keep the async logic outside React and let the component only own the result.fetch does not throw on 404 or 500 — check res.ok
Jul 29, Plaid front-end prep. fetch only rejects on network failure, CORS, or abort. A 404 or 500 resolves normally, so .then(r => r.json()) happily parses an error page and you render undefined with no error state. The res.ok check is the fix and it's the thing interviewers specifically look for.
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`); // ← the missing line
const data = await res.json();status string, not two booleans. isLoading + error lets you represent loading-AND-errored, which is impossible. status = "idle" | "loading" | "success" | "error" can't. Also: set status in both the success and error branches rather than a finally, so an ignored run never flips loading off.Stale-response races — the ignore flag vs AbortController
Any fetch keyed off props or state can resolve out of order. Type a then ab: if the a request is slow it lands last and overwrites the correct results. The fix is per-effect-run invalidation in the cleanup function, not a single mounted ref.
useEffect(() => {
let ignore = false; // scoped to THIS run of the effect
(async () => {
const data = await fetchJson(url);
if (ignore) return; // a newer run owns the state now
setData(data);
})();
return () => { ignore = true; }; // cleanup runs before the next run
}, [url]);
// stronger: also cancels the network request
useEffect(() => {
const controller = new AbortController();
fetchJson(url, { signal: controller.signal })
.then(setData)
.catch((e) => { if (e.name !== "AbortError") setStatus("error"); });
return () => controller.abort();
}, [url]);AbortError. An abort you caused is expected control flow, not a failure — showing an error banner for it is a visible bug. Check err.name === "AbortError" before setting error state.Endpoint shapes to ask about before writing state
Case 1 is the one to expect in a 60-minute exercise and it's written out in react/skeleton-fetch-for-plaid.jsx. The rest are worth recognizing on sight so you can name the approach even if you don't build it.
1. one endpoint, full objects → fetch once on mount, filter client-side
2. ids endpoint + item-by-id → Promise.all hydration, one page at a time
3. independent endpoints → Promise.all, never await in a loop
Promise.allSettled if partial data is OK
4. changing input (search/filter) → refetch + AbortController + debounce
5. pagination → offset ?page= | cursor ?cursor= | client slicenew URLSearchParams({...}) instead of string-concatenating query params — it encodes & and spaces for you.CSS
Most-used CSS properties
/* spacing */ margin: 16px; // outside (transparent) margin: 8px 16px; // top/bottom left/right padding: 16px; // inside (background shows) /* sizing */ width: 80px; height: 100%; width: fit-content; // shrink-wrap to content max-width: 600px; /* flexbox (apply to parent) */ display: flex; flex-direction: column; // row (default) | column justify-content: center; // main axis: flex-start | center | space-between align-items: center; // cross axis: flex-start | center | stretch gap: 12px; flex-wrap: wrap; /* positioning */ position: relative; // offset from normal flow, still takes up space position: absolute; // pinned to nearest relative ancestor position: fixed; // pinned to viewport (modals, navbars) position: sticky; // sticks at scroll threshold: top: 0 top: 0; right: 0; /* text */ font-size: 24px; font-weight: 600; color: #333; text-align: center; text-decoration: none; // remove underline from links /* box */ background: white; background-color: #f0f0f0; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); opacity: 0.5; /* misc */ cursor: pointer; overflow: hidden; // clip content that exceeds the box z-index: 100; // stacking order (higher = on top) display: none; // remove from layout entirely visibility: hidden; // invisible but still takes up space
CSS Grid — 2D layouts, boards, card grids
Apply to the parent. Children auto-place left-to-right, wrapping to the next row. Perfect for tic tac toe — a flat array of 9 cells becomes a 3×3 board with no row divs needed.
/* 3×3 fixed board */ .board { display: grid; grid-template-columns: repeat(3, 80px); // 3 cols, 80px each grid-template-rows: repeat(3, 80px); // 3 rows, 80px each gap: 4px; } /* responsive card grid — fills available width */ .card-grid { display: grid; grid-template-columns: repeat(3, 1fr); // 1fr = 1 fraction of remaining space gap: 16px; } // JSX — flat array, no row divs needed <div className="board"> {board.map((mark, i) => <Cell key={i} mark={mark} />)} </div>
Use Grid when
- 2D layout — rows AND columns
- Fixed cell sizes (boards, calendars)
- Card/tile grids
Use Flexbox when
- 1D layout — a row of buttons or a column of items
- Content-driven sizing
- Centering a single element
Inline style vs CSS file
In React, inline styles use camelCase keys and string values. CSS files use kebab-case and no quotes. In interviews on CoderPad (single file), inline is faster and totally fine.
// inline — camelCase, string values, double braces (outer = JSX, inner = object) <button style={{ fontSize: "24px", backgroundColor: "white", marginTop: "8px" }}> // CSS file — kebab-case, no quotes on values .cell-button { font-size: 24px; background-color: white; margin-top: 8px; } <button className="cell-button"> // className not class in JSX
Inline
- Fast in single-file interviews
- Co-located with the element
- Easy to make dynamic:
style={{ color: isError ? "red" : "black" }}
CSS file
- Reusable across components
- Supports hover, focus, media queries (inline can't)
- Cleaner JSX
:hover, :focus, or @media queries — those require a CSS class.<ul> / <ol> wrap <li> directly — no extra divs
Use <ul> when order is irrelevant, <ol> for steps or rankings. <li> must be a direct child — wrapping in a <div> is invalid HTML.
// setting display:flex on li kills the bullet — put flex on an inner div
<li>
<div style={{ display: "flex", gap: "4px" }}>
<span>{task.summary}</span>
<button onClick={() => removeTask(task.id)}>Delete</button>
</div>
</li>Links in JSX — <a> + target="_blank" always needs rel
In JSX an anchor is a normal <a> tag, but href and target take curly-brace expressions for dynamic values. To open in a new tab, add target="_blank" — and it must always ride with rel="noopener noreferrer".
// conditional link — only render an anchor when a url exists <b> {url ? ( <a href={url} target="_blank" rel="noopener noreferrer"> {title} </a> ) : ( title // no url → plain text, avoids href={undefined} )} </b>
target="_blank" without rel="noopener noreferrer". Without noopener, the opened page receives a reference to your page via window.opener and can redirect it to a phishing site (reverse tabnabbing). noreferrer also strips the Referer header. Interviewers watch for this — it's a one-line security detail that's easy to forget.<a href={undefined}> is still clickable and confusing. The ternary falls back to the plain title string when url is absent (common on Hacker News job posts).<table> structure — cells in rows in sections
A table is rows of cells; the sections are optional grouping. The nesting is strict: content only lives in <td>/<th>, cells only in <tr>, rows only in a section or straight in the table. Text or a <div> as a direct child of <table> or <tr> is invalid and the browser hoists it out.
<table> <thead> // groups header row(s) <tr> <th scope="col">Vehicle</th> // header cell, bold + announced as the column's label <th scope="col">Status</th> </tr> </thead> <tbody> // groups data rows <tr> // table row <td>wm-101</td> // data cell — your content goes here <td>stopped</td> </tr> </tbody> </table>
Roles: <table> container · <tr> a row · <td> a data cell · <th> a header cell (add scope="col" or scope="row" so screen readers know which cells it labels) · <thead>/<tbody> group rows into header/body sections.
<tbody>: write <tr> straight inside <table> and the browser inserts a <tbody> for you. So the DOM always has one — querySelector('table > tr') returns nothing because the <tr> is actually under the injected <tbody>.table → thead/tbody → tr → th/td, plus scope on <th>. <thead>/<tbody> are optional but writing them is the "I know semantic tables" signal. Prefer a real <table> over a div-grid once headers are interactive — <th> + scope give you accessible column labels for free.Input & Keyboard Events
Which event does what — onChange vs onKeyDown vs onPaste
Text-entry UIs (like an OTP input) split cleanly across three handlers. Don't rebuild typing from onKeyDown alone — it can't see paste and forces you to filter every non-character key.
// onChange — the VALUE changed. Owns data entry + forward focus. onChange={(e) => handle(e.target.value, i)} // e.target.value is always a string // onKeyDown — KEY identity. Owns Backspace-on-empty + arrow nav. onKeyDown={(e) => { // e.key = "5" | "Backspace" | "ArrowLeft"... if (e.key === "Backspace" && digits[i] === "") focusPrev(); }} // onPaste — has its OWN event; keys can't detect a paste. onPaste={(e) => { const text = e.clipboardData.getData("text"); // the pasted string }}
onChange does NOT fire on Backspace in an already-empty box (value didn't change), so moving focus back needs onKeyDown. And e.key is a key NAME, not the resulting value — validating it like a character lets "ArrowLeft" through.keydown → input/change → keyup. So keydown sees the value BEFORE this keystroke, keyup sees it after. Read the new value in onChange, not onKeyDown.e.preventDefault() — cancel the browser's built-in behavior
Call it whenever you take over what the browser would do by default, so the native action doesn't fight your logic.
// form submit — stop the full-page reload that wipes React state onSubmit={(e) => { e.preventDefault(); doSubmit(); }} // empty-box Backspace — move focus back WITHOUT the native delete // also erasing the previous box's digit (one press = one action) if (e.key === "Backspace" && digits[i] === "") { e.preventDefault(); focusPrev(); } // paste — stop default insertion so YOUR logic fills the boxes onPaste={(e) => { e.preventDefault(); spread(e.clipboardData.getData("text")); }}
preventDefault. Leaving the default alone → don't.