Learning Log [Front-End]

Waymo TPS prep — June 2026

TODO
React

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.
controlled inputJSXautoFocushigh priority

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
re-renderuseRef

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]);
}
Waymo interview — Mon Jul 6, 2026 (Fleet Monitoring, 45 min). File-tree component with indentation + open/close toggling, plus extensions. Landed the rendered tree and per-node toggle. Two misses: (1) put 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.
Review: re-solve the expandable tree view from scratch, timed to 30 min, on Jul 12 (already the Retool block). Two reps: one recursive collect + lifted Map state, one BFS. Retry the setState-placement decision out loud each time (handler vs effect vs lazy init). Cross-ref the DSA journal's tree traversal notes.
setStateuseEffectlazy initializergotchainterview loghigh priority

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
gotchastatehigh priority

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 ( ... );
}
If a value can be computed from existing state, don't make it state. Over-stating causes sync bugs and extra reset logic — e.g. storing filteredUsers as state means manually keeping it in sync with users and searchQuery. Use useMemo instead and let React recompute it.
Minimal state for tic tac toe: just board, isXTurn, isComputerThinking. Everything else (winner, draw, isGameOver) follows from the board.
statederived valuesstructurehigh priority

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
batchingre-render

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);
Default to 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.
setStatefunctional updategotchahigh priority

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
Effects are deferred because rendering must stay pure and side-effect-free. Touching the DOM (focus, measure) only makes sense after it exists. 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.
useEffectlifecyclerenderhigh priority

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>
Data flow is one direction: click → dispatch(action) → React runs reducer(state, action) → new state → re-render. The child stays dumb, all rules live in the reducer.
Always dispatch an OBJECT with a 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 }.
The reducer must be pure. Return a NEW object/array, never mutate the old state (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
3rd argument = lazy init. Pass the init function directly (no ()) 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.
useReducerstatedispatchhigh priority
React

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>;
};
Always declare all hooks (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.
gotchahookshigh priority

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>;
};
propsdestructuringhigh priority

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>
Arrow with curly braces => {'{ ... }'} is a function body — needs explicit return. Arrow with parens => ( ... ) is an implicit return. Blank list = missing return.
mapkeyslist renderinghigh priority

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>
The form wraps all related inputs. Each input still manages its own state. type="submit" on the button triggers onSubmit; type="button" does not.
Always call 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.
formsonSubmithigh priority

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}`);
  }
}
Enter submits to the first enabled button. Since 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
formsonSubmitFormData

<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>
Cancel-then-reselect bug: when 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.
selectlistboxgotchaforms

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.
useMemouseCallbackperformancedebounce

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>;
});
memo compares props with === (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 }), []);
Three related tools: 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.
memouseCallbackre-renderhigh priority

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
Init with [], not nullnull[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.
useRefrefsfocushigh priority
JavaScript

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)
  • .size built 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
Interview default: plain {'{}'} for string keys. Reach for Map when keys are numbers, objects, or when building adjacency lists.
Mapobjecthigh priority

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(...);
useState returns a tuple so you can name state and setter whatever makes sense for your use case.
destructuringhigh priority

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
Store each fact once. The state's identity is the key — don't also stash a value: "HIT" field that just repeats it (it drifts). Color and next state are derived, so they belong in the config.
Brackets also allow keys that aren't plain identifiers — expressions and template strings: { [`${prefix}_1`]: true } → key "ship_1". Without brackets, { prefix: 1 } would use the literal word "prefix".
computed keysobjectsconfig pattern

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) { ... }
gotchaiteration

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);
});
Don't use a > b ? 1 : -1 for strings — it's less reliable across locales and special characters. localeCompare handles accents, case, and language rules correctly.
sortingstrings

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
Prefer an anchored positive check (/^[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.
regextestvalidation

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 { ... }
TypeScriptenum
JavaScript

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);
mapfilterreducehigh priority

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]);
In this exact chain 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.
Rule: any time you .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.
The React-specific reason, added Jul 29. Mutating a state array returns the same reference, so 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.
Default sort is lexicographic, not numeric. [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.
sortmutationslicegotchahigh priority

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]
In practice .filter().map() is more readable. Reach for flatMap when working with already-nested arrays.
flatMaparray
JavaScript

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 delay
event loopmicrotask

setTimeout 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()
setTimeoutuseRefgotcha

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.
requestAnimationFrameperformance.nowcancelAnimationFramegotchahigh priority

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.
requestAnimationFramesequencinghigh priority

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]);
React runs the cleanup in two cases: (1) before the effect re-runs because a dependency changed, and (2) when the component is removed from the DOM (unmount). This guarantees only one timer is active at a time.
When does a component unmount? When it's removed from the DOM — e.g. conditional rendering ({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.
useEffectsetTimeoutcleanupgotcha

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
Always wrap 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)} />
Why useMemo here but useRef for a manual setTimeout debounce? Two different jobs. 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.
Always cancel pending calls on unmount. A debounced call can fire after the component is gone and call 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.
Purist caveat: 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.
debouncethrottleuseMemocleanupgotchahigh priority

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 mount
Within one async function, await 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.
fetchasync/awaituseEffectgotchahigh priority

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())
  )
);
Parallel inside, ordered outside. The requests race in no guaranteed order, but 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.allparallelfetchhigh priority

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()]);
Decision table. Need all results → 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.
None of them cancel the losers. 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.
PromiseraceanyallSettledgotcha

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]);
The 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.
Bonus trick worth knowing: 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.
AbortControllerAbortErroruseEffect cleanuphigh priority

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
How to tell which one is being asked for. "Time out after 60s" = total. "If nothing new arrives for 60s" = idle. The second sentence is the one that appeared in the Waymo prompt, and an idle timeout can legitimately run for ten minutes as long as something lands every 59 seconds. Ask which one they mean — it's a fair clarifying question and it shows you spotted the distinction.
Always 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.
setTimeoutclearTimeoutidle timeoutuseRefgotcha

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.

Shape to re-derive, not yet solved cleanly: kick off the best-quality fetch, race it against a 60s timer (manual 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.
Outcome: only failed round of the four. Waymo asked for one replacement round, so this exact shape is the thing to have cold. Worked solution below.

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
  });
}
The four things being tested, and where each shows up.
· Idle vs total timeoutarm() calls clearTimeout before re-setting. That reset is the answer to "if nothing new arrives for 60s."
· Early exitbestIdx === 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.
Two things to say out loud before they ask. (1) Calling finish() more than once is safe — resolve is idempotent, so the aborted fetches hitting .catch.finallyfinish() 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]);
State vs refs. 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.
fetchAbortControlleridle timeoutfinallyres.okworked solutionhigh priority

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();
Model state as one 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.
Three render branches is actually four — loading, error, empty, and data. The empty state is the one most candidates skip and it's nearly free.
fetchres.okerror handlinggotchahigh priority

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]);
Always swallow 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.
Debounce and abort are complements, not alternatives: debounce fires fewer requests, abort discards the ones already in flight. A typeahead wants both. And StrictMode's double-mount is the free test for this — if the effect isn't cleanup-safe, dev mode shows it.
race conditionAbortControlleruseEffect cleanupStrictModegotchahigh priority

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 slice
Ask, don't assume, whether filtering is server-side or client-side. Client-side means one fetch on mount and zero race conditions — a completely different amount of code. Same for pagination: cursor-based can't be parallelised, offset-based can.
Other first-3-minutes questions: auth header? roughly how many records (decides pagination/virtualization)? should sort/filter survive reload (URL params vs state)? is CORS open for localhost? Use new URLSearchParams({...}) instead of string-concatenating query params — it encodes & and spaces for you.
fetchpaginationclarifying questionsPlaidhigh priority
HTML / CSS

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
CSShigh priority

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
gridhigh priority

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
Inline styles can't express :hover, :focus, or @media queries — those require a CSS class.
CSSinterviewhigh priority

<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>
HTMLCSSflexbox

<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.

Auto-<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>.
Interview core: 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.
HTMLtablesemanticsa11y
Events

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
}}
Why a combination: 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.
Timing: for one keypress the order is keydowninput/changekeyup. So keydown sees the value BEFORE this keystroke, keyup sees it after. Read the new value in onChange, not onKeyDown.
eventsonChangeonKeyDownonPaste

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")); }}
Rule of thumb: taking over a default (submit, paste, a key's native effect) → call preventDefault. Leaving the default alone → don't.
preventDefaulteventsforms