Frontend Developer — Concept Notes

Interview Q&A

Concept-first, in depth. Examples use plain JS and React, but the ideas apply to any frontend stack. Print this page for a PDF.

Part 1 — Foundations

1. What Frontend Really Is

The frontend is everything that runs on the user's device — code you ship but don't control. It is downloaded over a network you can't predict, executed on hardware you can't choose, and read by people using a screen reader, a 5-year-old Android, or a keyboard only.

Core responsibilities

  • Structure — meaningful HTML that describes what content is, not how it looks.
  • Presentation — CSS that stays predictable as the project grows.
  • Behaviour — JavaScript that reacts to input without blocking the page.
  • State management — keeping the UI and the data in sync; this is where most bugs live.
  • Performance — bytes shipped, time to interactive, smooth scrolling.
  • Accessibility — usable by keyboard, screen reader, and low vision users.
  • Resilience — the network fails, APIs return 500, users double-click. Handle it.
Golden rule: the client is untrusted and unreliable. Frontend validation is for user experience; the backend still has to validate everything. Never put a secret in frontend code — anyone can read your bundle.

Key mental model — the render lifecycle

User types a URL → DNS + TCP/TLS → HTML arrives as a stream → Parser builds the DOM ↳ <link rel=stylesheet> → CSSOM (render blocking) ↳ <script> → pauses parsing unless defer/async → DOM + CSSOM = Render Tree → Layout (where is every box? "reflow") → Paint (fill in pixels) → Composite(stack the layers on the GPU) → JS runs → state changes → back to Layout/Paint/Composite

Almost every frontend performance question reduces to: how do I do less work in this pipeline, or move it off the critical path?

2. How the Browser Works

The pieces

  • Rendering engine — Blink (Chrome, Edge), WebKit (Safari), Gecko (Firefox). Parses HTML/CSS, lays out and paints.
  • JS engine — V8, JavaScriptCore, SpiderMonkey. Parses and JIT-compiles JS.
  • Browser process vs renderer process — each tab typically gets its own sandboxed renderer, so one crashed tab doesn't kill the browser.
  • The main thread does parsing, layout, paint, and your JavaScript. That's why a long loop freezes scrolling.

Critical rendering path

The browser cannot paint until it has both DOM and CSSOM. So CSS is render-blocking by default, and a synchronous <script> in the head blocks parsing entirely.

Script tagDownloadsExecutesUse for
<script>Immediately, blocking parseImmediatelyAlmost never in head
asyncParallelAs soon as ready, out of orderIndependent scripts (analytics)
deferParallelAfter parsing, in orderYour app code — the default choice
type="module"ParallelDeferred by defaultES module entry points

Reflow vs repaint vs composite

  • Reflow (layout) — geometry changed: width, top, font-size, adding a node. Expensive, can cascade to the whole document.
  • Repaint — pixels changed but geometry didn't: color, background, box-shadow. Cheaper.
  • Composite only — transform and opacity can be handled by the GPU without touching layout or paint. This is why you animate transform, not top/left.

Layout thrashing

Reading a layout property (offsetHeight, getBoundingClientRect) forces the browser to flush pending style changes so the value is correct. Interleaving reads and writes in a loop forces a reflow every iteration.

// Bad — read/write/read/write forces N reflows
items.forEach(el => { el.style.height = el.offsetHeight + 10 + "px"; });

// Good — batch all reads, then all writes
const heights = items.map(el => el.offsetHeight);
items.forEach((el, i) => { el.style.height = heights[i] + 10 + "px"; });

3. HTML & Semantics

Semantic HTML means choosing the element that describes the meaning of the content. It is free accessibility, free SEO, and free default behaviour.

Structural elements

ElementMeaning
<header> / <footer>Intro / closing content for its nearest section or the page
<nav>A block of major navigation links
<main>The primary content. One per page — screen readers can jump to it
<article>Self-contained and independently distributable (a post, a card)
<section>A thematic grouping — should have a heading
<aside>Tangential content (sidebar, callout)
<figure>/<figcaption>Media with a caption tied to it
<time datetime>A machine-readable date
Interview favourite: why <button> over <div onclick>? A button is focusable, fires on Enter and Space, is announced as "button" by screen readers, can be disabled, and participates in forms. Recreating all of that on a div takes tabindex, two key handlers, and role="button" — and you'll still miss something.

The document head

<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page title — Site</title>
<meta name="description" content="Shown in search results">
<meta property="og:title" content="Shown when shared on social">
<link rel="canonical" href="https://site.com/page">
<link rel="preconnect" href="https://api.site.com">

Without the viewport meta tag, mobile browsers render at ~980px wide and zoom out — your media queries will look like they don't work.

Images

  • alt="" (empty) for decorative images — this hides them from screen readers, which is correct. Omitting alt entirely makes the reader announce the filename.
  • width and height attributes let the browser reserve space and prevent layout shift (CLS).
  • loading="lazy" for below-the-fold images; never for the hero image.
  • srcset / <picture> serve different sizes or formats (AVIF/WebP with a JPEG fallback).
<img src="hero-800.jpg" alt="Team at work"
     srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1600.jpg 1600w"
     sizes="(max-width: 700px) 100vw, 800px"
     width="800" height="450">

4. Forms & Inputs

The label is not optional

<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email">

A correctly associated label is announced by screen readers and expands the click target. A placeholder is not a label — it disappears as soon as the user types.

Input types earn you behaviour for free

  • type="email", tel, number, url — change the mobile keyboard.
  • type="search", date, color, range, file — native widgets.
  • required, min, max, pattern, minlength — native validation and the :invalid pseudo-class.
  • autocomplete — a real UX win; browsers fill addresses and cards correctly when you use the standard tokens.

Controlled vs uncontrolled

An uncontrolled input keeps its value in the DOM; you read it on submit. A controlled input keeps its value in JS state and re-renders on every keystroke. Controlled gives you instant validation and derived UI; uncontrolled is cheaper and simpler for big forms.

// React — controlled
const [email, setEmail] = useState("");
<input value={email} onChange={e => setEmail(e.target.value)} />

// React — uncontrolled
const ref = useRef();
<input ref={ref} defaultValue="" />   // read ref.current.value on submit

Submitting properly

form.addEventListener("submit", async (e) => {
  e.preventDefault();                       // stop the full page reload
  const data = Object.fromEntries(new FormData(form));
  btn.disabled = true;                      // stop double submits
  try { await api.save(data); }
  finally { btn.disabled = false; }
});
Keep the form a real <form> with a type="submit" button even in a SPA — you get Enter-to-submit, native validation, and password manager support for free.
Part 2 — CSS

5. The Cascade & Specificity

When two rules target the same element, the browser resolves the conflict in this order:

  1. Origin & importance — author !important beats author normal, which beats user-agent defaults.
  2. Specificity — a score per selector.
  3. Source order — the last matching rule wins.

Specificity scoring (a, b, c)

SelectorScoreNote
#id1,0,0Very hard to override — avoid in component CSS
.class, [attr], :hover0,1,0The sweet spot
div, ::before0,0,1Element and pseudo-element
*, :where()0,0,0:where() always scores zero
inline style=""above all selectorsOnly !important beats it

Compare left to right: 0,1,0 beats 0,0,9. Ten element selectors never beat one class.

.card .title      { color: blue; }   /* 0,2,0 — wins */
#page .title      { color: red;  }   /* 1,1,0 — actually this wins */
:where(#page) .title { color: green; } /* 0,1,0 — :where() zeroes the id */

Inheritance

Text-ish properties inherit (color, font-*, line-height, visibility). Box properties do not (margin, padding, border, background). You can force either with inherit, initial, unset, or revert.

Why !important is a smell: it wins the cascade but takes the tool away from the next person — the only way to beat it is another !important. Fix the specificity instead. Legitimate uses: utility classes and overriding third-party CSS you can't edit.

6. The Box Model & Sizing

Every element is a box: content → padding → border → margin.

┌──────────── margin ─────────────┐ │ ┌────────── border ───────────┐ │ │ │ ┌──────── padding ────────┐ │ │ │ │ │ content │ │ │ │ │ └─────────────────────────┘ │ │ │ └─────────────────────────────┘ │ └─────────────────────────────────┘

box-sizing

  • content-box (default) — width sets the content only; padding and border are added on top. A 200px box with 20px padding renders 240px wide.
  • border-box — width includes padding and border. 200px means 200px.
*, *::before, *::after { box-sizing: border-box; }  /* set this once, always */

Margin collapsing

Adjacent vertical margins between block elements merge into the largest of the two — 30px below + 20px above = 30px gap, not 50px. Horizontal margins never collapse. Collapsing is prevented by padding, a border, overflow other than visible, or a flex/grid container.

Units

UnitRelative toGood for
pxNothingBorders, fine detail
remRoot font-sizeSpacing and type — respects user zoom settings
emOwn font-sizePadding that scales with its own text; compounds when nested
%Parent's same dimensionFluid widths
vw / vh / dvhViewportFull-screen sections; dvh handles mobile browser chrome
chWidth of "0"Readable line length — max-width: 65ch
frFree space in a gridGrid tracks only
Use rem for font sizes. If a user sets a larger default font for readability, px ignores them and rem respects them.

7. Layout: Normal Flow & Positioning

display

  • block — full width, stacks vertically, respects width/height.
  • inline — flows with text; width, height and vertical margin are ignored.
  • inline-block — flows inline but accepts box dimensions.
  • flex / grid — establish a new layout context for children.
  • none — removed from the tree entirely (vs visibility: hidden, which keeps the space, and opacity: 0, which keeps the space and stays clickable).

position

ValuePositioned relative toIn flow?
staticNothing (default)Yes
relativeIts own normal positionYes — original space kept
absoluteNearest positioned ancestorNo
fixedThe viewportNo
stickyScroll position, within its parentYes

position: sticky needs a threshold (top: 0) and only sticks inside its parent's box — if it isn't working, the parent usually has overflow: hidden or no height.

Stacking contexts & z-index

z-index only compares elements within the same stacking context. A new context is created by: a positioned element with a z-index, opacity < 1, transform, filter, will-change, or a flex/grid child with a z-index.

Classic bug: "my modal has z-index: 9999 and still hides behind the header." The modal's ancestor has transform or opacity, creating a context — so 9999 only competes inside that box. Fix: move the modal to a portal at the body root.

Floats and overflow

Floats are legacy layout but still appear for wrapping text around an image. Modern replacement for clearfix: display: flow-root on the parent, which contains floats without hacks.

8. Flexbox

Flexbox is one-dimensional: you lay out along a main axis, and the cross axis is handled for you. Reach for it for toolbars, cards in a row, centring, and anything where content size should drive the layout.

Container properties

.container {
  display: flex;
  flex-direction: row | row-reverse | column | column-reverse;
  flex-wrap: nowrap | wrap;
  justify-content: flex-start | center | space-between | space-around | space-evenly;
  align-items: stretch | flex-start | center | baseline;   /* cross axis */
  align-content: /* spacing of multiple lines — only with wrap */;
  gap: 16px;                                               /* use this, not margins */
}

Remember: justify-content works on the main axis, align-items on the cross axis. Change flex-direction to column and the two swap meaning.

Item properties

.item {
  flex-grow: 0;      /* share of leftover space to absorb */
  flex-shrink: 1;    /* how eagerly to shrink when space is tight */
  flex-basis: auto;  /* starting size before grow/shrink */
  flex: 1;           /* shorthand for 1 1 0%  — equal columns */
  flex: auto;        /* 1 1 auto — sized by content, then grows */
  align-self: center;/* override align-items for one item */
  order: 2;          /* visual order only — does NOT change tab order */
}
flex: 1 vs flex: auto: flex: 1 sets basis to 0, so items end up equal width regardless of content. flex: auto starts from content width, so a longer item stays wider. Interviewers ask this a lot.

Two things that bite

  • A flex item's min-width defaults to auto, so it refuses to shrink below its content — long text or a wide <pre> overflows the container. Fix with min-width: 0.
  • Perfect centring is just display:flex; justify-content:center; align-items:center;.

9. Grid

Grid is two-dimensional — you define rows and columns and place items into them. Use it for page structure and any real grid of content.

.grid {
  display: grid;
  grid-template-columns: 200px 1fr 1fr;         /* fixed sidebar + fluid */
  grid-template-rows: auto 1fr auto;
  gap: 20px;
}

/* Responsive with no media queries at all */
.cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 16px;
}

auto-fill keeps empty tracks; auto-fit collapses them so the items stretch to fill the row. minmax(250px, 1fr) means "at least 250px, otherwise share the space".

Placing items

.hero  { grid-column: 1 / 3; }        /* from line 1 to line 3 */
.hero  { grid-column: span 2; }       /* same thing, relative */
.full  { grid-column: 1 / -1; }       /* full width, whatever the count */

/* Named areas — very readable for page layout */
.page {
  display: grid;
  grid-template-columns: 240px 1fr;
  grid-template-areas:
    "sidebar header"
    "sidebar main"
    "sidebar footer";
}
.sidebar { grid-area: sidebar; }

Grid vs Flexbox

FlexboxGrid
DimensionsOne axis at a timeRows and columns together
Driven byContentThe layout you declare
Best forNavbars, button rows, centringPage shells, card grids, dashboards

They compose: a grid for the page, flexbox inside each cell.

10. Responsive Design

Mobile first

Write the small-screen styles as the base, then add complexity with min-width queries. It keeps the CSS additive instead of a pile of overrides.

.grid { display: grid; gap: 16px; }                 /* 1 column by default */
@media (min-width: 640px)  { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .grid { grid-template-columns: repeat(3, 1fr); } }

Fluid type and space

h1 { font-size: clamp(1.75rem, 1.2rem + 2.5vw, 3rem); }
/*               min      preferred(scales)    max  */
.section { padding-inline: max(1rem, 5vw); }

clamp(), min() and max() replace whole stacks of media queries.

Container queries

Media queries ask about the viewport; container queries ask about the parent — so a component can adapt wherever it's dropped.

.card-wrap { container-type: inline-size; }
@container (min-width: 400px) {
  .card { display: grid; grid-template-columns: 120px 1fr; }
}

Other responsive concerns

  • Touch targets of at least 44×44px.
  • @media (prefers-color-scheme: dark) and (prefers-reduced-motion: reduce) — respect both.
  • 100vh on mobile includes the browser chrome and causes jumping; prefer 100dvh.
  • Test with the keyboard open and at 200% zoom, not just by dragging the window.

11. Transitions & Animation

.btn {
  transition: background-color .2s ease, transform .2s ease;
}
.btn:hover { transform: translateY(-2px); }

@keyframes slide-in {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: translateY(0); }
}
.panel { animation: slide-in .3s ease-out both; }

Animate only cheap properties

Stick to transform and opacity — they are composited on the GPU and skip layout and paint entirely. Animating width, top, margin or box-shadow forces layout or paint every frame and drops you below 60fps.

Instead ofAnimate
left / toptransform: translate()
width / heighttransform: scale()
display: noneopacity + visibility, or @starting-style

Respect motion preferences

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    transition-duration: .01ms !important;
  }
}

This isn't a nicety — motion triggers nausea and migraines for some users.

CSS vs JS animation

CSS for state changes and simple sequences (it can run off the main thread). JS (Web Animations API, GSAP, Framer Motion) when you need to interrupt, chain, or drive animation from scroll or gesture position.

12. Modern CSS & Architecture

Custom properties

:root { --brand: #2f6f4e; --space: 8px; }
html[data-theme="dark"] { --brand: #7fc8a0; }
.btn { background: var(--brand); padding: calc(var(--space) * 2); }

Unlike Sass variables, these are live at runtime — they cascade, inherit, and can be read and set from JavaScript (el.style.setProperty("--brand", "#333")). This is how theme switching works with no rebuild.

Useful modern selectors

  • :is(h1, h2, h3) code — grouping; takes the highest specificity inside.
  • :where(...) — same grouping but zero specificity; perfect for resets.
  • :has(> img) — the long-awaited parent selector. .card:has(img) styles cards that contain an image.
  • :focus-visible — focus ring only for keyboard users, not mouse clicks. Never do outline: none without this.
  • & native nesting, @layer for cascade layers, aspect-ratio, inset: 0.

Naming and structure

ApproachIdeaTrade-off
BEM.card__title--largeFlat specificity, verbose names
Utility-first (Tailwind)Compose from small classesVery fast; markup gets noisy
CSS ModulesHashed local class namesReal scoping, needs a build step
CSS-in-JSStyles colocated in componentsDynamic; runtime cost unless compiled
Whatever you pick, the goal is the same: deleting a component should delete its CSS. Global stylesheets fail this, which is why they grow forever.
Part 3 — JavaScript

13. Types & Coercion

The seven primitives

string, number, boolean, null, undefined, symbol, bigint. Everything else — objects, arrays, functions, dates — is an object.

  • Primitives are copied by value; objects are copied by reference.
  • typeof null === "object" — a 1995 bug kept for compatibility.
  • typeof [] is "object"; use Array.isArray().
  • undefined = "not assigned yet"; null = "deliberately empty".

Falsy values — memorise these

false, 0, -0, 0n, "", null, undefined, NaN. Everything else is truthy — including [], {}, "0" and "false".

== vs ===

0 == "";          // true   — both coerced to 0
0 == "0";         // true
"" == "0";        // false  — not transitive!
null == undefined // true   — the one loose comparison worth using
null === undefined// false
NaN === NaN       // false  — use Number.isNaN()
[] == false       // true   — [] → "" → 0

Rule: always ===. The single exception is x == null to test for null-or-undefined in one go.

Copying

const a = { x: 1, nested: { y: 2 } };
const shallow = { ...a };                  // nested is still shared
const deep    = structuredClone(a);        // real deep copy, built in

let n = 5;  let m = n;  m = 10;            // n is still 5   (by value)
let o = {v:5}; let p = o; p.v = 10;        // o.v is now 10  (by reference)

Nullish operators

const port = config.port ?? 3000;   // only falls back on null/undefined
const port = config.port || 3000;   // ALSO falls back on 0 — a bug
user?.profile?.name;                // optional chaining, no TypeError
obj.method?.();                     // call only if it exists

14. Scope, Hoisting & Closures

Declarations

varletconst
ScopeFunctionBlockBlock
HoistedYes, as undefinedYes, but in the TDZSame as let
RedeclareAllowedNoNo
ReassignYesYesNo (binding, not the value)

The Temporal Dead Zone is the gap between entering a scope and the let/const line — accessing the variable there throws a ReferenceError, which is far better than var's silent undefined.

const obj = {} then obj.a = 1 is legal: const freezes the binding, not the contents. Use Object.freeze() for the contents.

The classic loop question

for (var i = 0; i < 3; i++) setTimeout(() => console.log(i));  // 3 3 3
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i));  // 0 1 2

var creates one binding shared by all three callbacks, which by the time they run holds 3. let creates a fresh binding per iteration, so each closure captures its own.

Closures

A closure is a function that keeps access to the variables of the scope where it was defined, even after that scope has returned. It's not magic — the function holds a reference to its lexical environment, so the garbage collector keeps it alive.

function counter() {
  let count = 0;                       // private — not reachable from outside
  return {
    inc:  () => ++count,
    get:  () => count,
  };
}
const c = counter();
c.inc(); c.inc();  c.get();            // 2

Where closures show up in real work

// Debounce — run only after the user stops typing
function debounce(fn, delay = 300) {
  let timer;                                  // captured by the returned fn
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

// Throttle — run at most once per interval
function throttle(fn, limit = 300) {
  let waiting = false;
  return (...args) => {
    if (waiting) return;
    fn(...args);
    waiting = true;
    setTimeout(() => { waiting = false; }, limit);
  };
}
Debounce vs throttle: debounce for search-as-you-type and resize (you only care about the final value); throttle for scroll handlers and infinite scroll (you want steady updates). Both are closure questions in disguise.

15. this, call, apply, bind

this is decided by how a function is called, not where it is written. Check these rules in order:

  1. new Fn() → this is the newly created object.
  2. Explicit — fn.call(obj), fn.apply(obj), fn.bind(obj) → this is obj.
  3. Method call — obj.fn() → this is obj (whatever is left of the dot).
  4. Plain call — fn() → undefined in strict mode / modules, window otherwise.

Arrow functions ignore all of this. They have no this of their own and take it from the enclosing lexical scope, permanently. bind cannot change it.

const user = {
  name: "Ada",
  greetRegular() { console.log(this.name); },
  greetArrow: () => console.log(this.name),
};
user.greetRegular();          // "Ada"
user.greetArrow();            // undefined — `this` is the module/window

const fn = user.greetRegular;
fn();                         // undefined — lost the object on the way
fn.call(user);                // "Ada"
const bound = fn.bind(user);
bound();                      // "Ada" — permanently attached

The callback trap

class Timer {
  constructor() { this.secs = 0; }
  startBroken() { setInterval(function () { this.secs++; }, 1000); } // this = window
  startOk()     { setInterval(() => { this.secs++; }, 1000); }       // arrow inherits
}

Same reason React class components needed this.handleClick = this.handleClick.bind(this) in the constructor — or a class field arrow function instead.

call vs apply vs bind

fn.call(ctx, a, b);     // invoke now, args listed
fn.apply(ctx, [a, b]);  // invoke now, args as an array
const g = fn.bind(ctx); // don't invoke — return a new bound function

16. Objects & Prototypes

The prototype chain

Every object has a hidden link ([[Prototype]], exposed as __proto__) to another object. When you read a property, JS walks up that chain until it finds it or hits null. That is the whole inheritance model — there are no classes underneath, just objects linking to objects.

const animal = { speak() { return `${this.name} makes a sound`; } };
const dog = Object.create(animal);
dog.name = "Rex";
dog.speak();                       // found on animal, `this` is dog
Object.getPrototypeOf(dog) === animal;  // true

Classes are syntax over prototypes

class Animal {
  #secret = "private";             // true private field
  static count = 0;
  constructor(name) { this.name = name; Animal.count++; }
  speak() { return `${this.name} makes a sound`; }   // lives on Animal.prototype
}
class Dog extends Animal {
  constructor(name, breed) {
    super(name);                   // must call before using `this`
    this.breed = breed;
  }
  speak() { return `${super.speak()} — a bark`; }
}

Methods live on the prototype and are shared by every instance; fields defined in the constructor are copied per instance.

Useful object work

Object.keys(o); Object.values(o); Object.entries(o);
Object.fromEntries(pairs);            // rebuild an object from entries
Object.assign(target, a, b);          // shallow merge
Object.freeze(o);                     // shallow immutability
o.hasOwnProperty("k");                // own property, not inherited
Object.hasOwn(o, "k");                // the modern, safer form

const { a, b: renamed, c = 5, ...rest } = obj;      // destructuring
const [first, , third = 0] = arr;

Map/Set vs object/array

  • Map — any key type (including objects), preserves insertion order, has .size, faster for frequent add/delete.
  • Set — unique values. [...new Set(arr)] deduplicates in one line.
  • WeakMap/WeakSet — keys held weakly, so entries are collected when the key is garbage. Good for caching data against DOM nodes without leaking.

17. Arrays & Iteration

MethodReturnsMutates?
mapNew array, same lengthNo
filterNew array, subsetNo
reduceAny single valueNo
find / findIndexFirst match / its indexNo
some / everyBooleanNo
sliceA copy of a rangeNo
spliceRemoved itemsYes
sort / reverseThe same arrayYes
flat / flatMapNew flattened arrayNo
at(-1)Last elementNo
toSorted / toReversedNew arrayNo — modern, safe
// reduce is just a loop with an accumulator
const total = items.reduce((sum, i) => sum + i.price, 0);

// group into an object
const byRole = users.reduce((acc, u) => {
  (acc[u.role] ||= []).push(u);
  return acc;
}, {});

// sort needs a comparator for numbers — default sort is lexicographic!
[10, 9, 1].sort();              // [1, 10, 9]  ← surprise
[10, 9, 1].sort((a, b) => a - b); // [1, 9, 10]

// sort mutates — copy first in React state
const sorted = [...items].sort((a, b) => a.n - b.n);

Loop choices

  • for...of — values of any iterable; supports break and works with await.
  • for...in — keys, including inherited ones. Avoid on arrays.
  • forEach — cannot break, and ignores async callbacks (a very common bug).
// Bad — forEach doesn't await; "done" prints first
items.forEach(async i => { await save(i); });
console.log("done");

// Sequential
for (const i of items) await save(i);
// Parallel
await Promise.all(items.map(i => save(i)));

18. The Event Loop

JavaScript is single-threaded: one call stack, one thing at a time. Concurrency comes from the runtime handing slow work (timers, network, DOM events) to the browser and queuing the callback for later.

┌─────────────┐ │ Call Stack │ ← runs one frame at a time └──────┬──────┘ │ when the stack is EMPTY ▼ ┌──────────────────────┐ │ Microtask queue │ ← promises, queueMicrotask, MutationObserver │ (drained COMPLETELY)│ a microtask can queue more — all of them run └──────┬───────────────┘ ▼ ┌──────────────────────┐ │ Render (if needed) │ ← requestAnimationFrame, style, layout, paint └──────┬───────────────┘ ▼ ┌──────────────────────┐ │ Macrotask (ONE) │ ← setTimeout, setInterval, I/O, events └──────────────────────┘ └─── repeat ───┘

The interview question

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
queueMicrotask(() => console.log("4"));
console.log("5");

// Output: 1, 5, 3, 4, 2

Sync code first (1, 5). Then the stack empties and all microtasks drain (3, 4). Only then one macrotask runs (2). This is why setTimeout(fn, 0) is never actually 0 — it's "after the current work and all pending microtasks".

Why it matters practically

  • A long synchronous loop blocks everything: no clicks, no scroll, no paint. Break work into chunks or move it to a Web Worker.
  • An infinite microtask chain starves rendering completely — microtasks drain fully before the browser is allowed to paint.
  • requestAnimationFrame runs just before paint, which is the right place for visual updates.
  • setTimeout is clamped to ~4ms after several nested calls, and throttled hard in background tabs.

19. Promises & async/await

A promise is an object representing a value that isn't ready yet. It is in one of three states — pending, fulfilled, or rejected — and once settled it never changes again.

const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve("done"), 1000);   // or reject(new Error("nope"))
});

p.then(v => v.toUpperCase())      // each .then returns a NEW promise
 .then(v => console.log(v))
 .catch(err => console.error(err))// catches any rejection above it
 .finally(() => stopSpinner());   // runs either way

async/await is the same thing, readable

async function load(id) {            // always returns a promise
  try {
    const res  = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);  // fetch does NOT throw on 404/500
    return await res.json();
  } catch (err) {
    console.error("Load failed:", err);
    throw err;                       // rethrow, or the caller thinks it worked
  }
}

Combinators

MethodResolves whenRejects when
Promise.allAll fulfil — array of valuesAny rejects (fail fast)
Promise.allSettledAll settle — {status, value/reason}[]Never
Promise.raceFirst to settle, either wayIf the first settled one rejected
Promise.anyFirst to fulfilOnly if all reject
// Sequential — 3 seconds
const a = await getA(); const b = await getB(); const c = await getC();

// Parallel — 1 second. Use this whenever the calls are independent.
const [a, b, c] = await Promise.all([getA(), getB(), getC()]);

// Don't let one failure kill the page
const results = await Promise.allSettled([getA(), getB()]);
const ok = results.filter(r => r.status === "fulfilled").map(r => r.value);
Two traps. (1) fetch only rejects on a network failure — a 500 response is a fulfilled promise, so you must check res.ok yourself. (2) An unhandled rejection is a silent bug in the browser; always .catch or wrap in try/catch.

20. Modules & Tooling

// named exports — many per file, imported by exact name
export const PI = 3.14;
export function area(r) { return PI * r * r; }
import { PI, area as circleArea } from "./math.js";

// default export — one per file, any name on import
export default function Button() {}
import Btn from "./Button.jsx";

// dynamic import — returns a promise, creates a separate chunk
const { Chart } = await import("./Chart.js");

ES modules are static (the imports are known before running), which is what makes tree shaking possible. They are also deferred and run in strict mode automatically.

ESM vs CommonJS

ESMCommonJS
Syntaximport / exportrequire / module.exports
LoadingStatic, asyncDynamic, synchronous
Tree shakingYesNo
WhereBrowsers + modern NodeLegacy Node

What a bundler does

  • Bundle — follow the import graph into a few files, avoiding hundreds of requests.
  • Transpile — Babel/SWC/esbuild convert modern JS and JSX to what browsers support.
  • Tree shake — drop exports nobody imports.
  • Code split — import() and route boundaries become separate chunks loaded on demand.
  • Hash filenames — app.8fa3b.js so you can cache forever and bust on change.

Vite serves native ESM in dev (instant startup, real HMR) and bundles with Rollup for production. Webpack is the older, more configurable equivalent.

Part 4 — The Browser

21. DOM Manipulation

document.querySelector(".card");        // first match, any CSS selector
document.querySelectorAll(".card");     // static NodeList — snapshot
document.getElementsByClassName("card");// LIVE HTMLCollection — updates itself

el.textContent = "safe";                // escapes everything — use this
el.innerHTML   = userInput;             // XSS risk — never with untrusted data
el.insertAdjacentHTML("beforeend", html);

el.classList.add("on"); el.classList.toggle("on", isOn);
el.dataset.userId = 42;                 // <div data-user-id="42">
el.setAttribute("aria-expanded", "true");
el.closest(".card");                    // walk UP to the nearest match
Attribute vs property: input.getAttribute("value") returns the initial HTML value; input.value returns what the user has actually typed. They diverge the moment someone types.

Creating nodes efficiently

// Each append to the live DOM can trigger layout — batch with a fragment
const frag = document.createDocumentFragment();
for (const item of items) {
  const li = document.createElement("li");
  li.textContent = item.name;
  frag.append(li);
}
list.append(frag);                     // one DOM operation

Measuring

  • getBoundingClientRect() — size and position relative to the viewport.
  • offsetWidth includes padding and border; clientWidth excludes the border; scrollWidth is the full content width.
  • Every one of these forces a synchronous layout — read them in a batch, never inside a write loop.

22. Events in Depth

The three phases

① CAPTURE (window → target) window → document → body → div.card → button ← ② TARGET ③ BUBBLE (target → window)

Listeners run in the bubble phase by default; pass { capture: true } for the way down. Most events bubble — focus, blur, load and scroll (on an element) do not. focusin/focusout are the bubbling versions of focus/blur.

Delegation

// One listener for a list of any size — works for items added later
list.addEventListener("click", (e) => {
  const btn = e.target.closest("[data-id]");
  if (!btn || !list.contains(btn)) return;
  remove(btn.dataset.id);
});

This is the single most useful event pattern: fewer listeners, less memory, and it survives dynamic content.

stopPropagation vs preventDefault

  • e.preventDefault() — cancel the browser's default action (form submit, link navigation, checkbox toggle). The event still propagates.
  • e.stopPropagation() — stop travelling to ancestors. The default action still happens.
  • e.stopImmediatePropagation() — also stop other listeners on this same element.

e.target is what was actually clicked; e.currentTarget is the element the listener is attached to. Inside a delegated handler they differ, and that's the point.

Listener options

el.addEventListener("scroll", onScroll, { passive: true });  // never calls preventDefault
el.addEventListener("click", once, { once: true });          // auto-removes itself

const ac = new AbortController();
el.addEventListener("move", fn, { signal: ac.signal });
ac.abort();                              // removes it — great for cleanup

{ passive: true } on scroll and touch listeners tells the browser it doesn't need to wait for your handler before scrolling — a real, measurable scroll performance win.

23. Storage & Cookies

localStoragesessionStorageCookiesIndexedDB
Size~5–10MB~5–10MB~4KBHundreds of MB
ExpiresNeverOn tab closeYou set itNever
Sent to serverNoNoEvery requestNo
APISync, strings onlySync, strings onlySync, stringAsync, structured
Use forTheme, draftsOne-tab flow stateSessions, authOffline data, files
localStorage.setItem("theme", "dark");
JSON.parse(localStorage.getItem("user") ?? "null");   // always guard — may be null
// Wrap in try/catch: private mode and blocked site data can throw.

Cookie flags that matter

Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Lax; Max-Age=604800; Path=/
  • HttpOnly — JS cannot read it. This is what protects a session token from XSS.
  • Secure — HTTPS only.
  • SameSite — Strict (never cross-site), Lax (sent on top-level navigation; the modern default), None (requires Secure). This is the main CSRF defence.
Where do I put the JWT? localStorage is readable by any injected script, so an XSS bug hands over the token. An HttpOnly cookie can't be read by JS but needs CSRF protection (SameSite + a CSRF token). The cookie is generally the safer default.

24. Fetch, HTTP & CORS

const res = await fetch("/api/users", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(user),
  credentials: "include",       // send cookies cross-origin
  signal: controller.signal,    // for cancellation
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();

Status codes worth knowing

CodeMeaning
200 / 201 / 204OK / Created / No content
301 / 302 / 304Moved permanently / temporarily / Not modified (cached)
400 / 401 / 403Bad request / Not authenticated / Authenticated but not allowed
404 / 409 / 422 / 429Not found / Conflict / Unprocessable / Rate limited
500 / 502 / 503Server error / Bad gateway / Unavailable

401 vs 403: 401 means "I don't know who you are" (log in); 403 means "I know who you are and you still can't".

Cancelling

const controller = new AbortController();
fetch(url, { signal: controller.signal })
  .catch(e => { if (e.name !== "AbortError") throw e; });
controller.abort();     // cancel a stale search request or on unmount

CORS

The browser's same-origin policy blocks JS from reading a response from a different origin (scheme + host + port). CORS is the server opting in with response headers. Two important points: CORS is enforced by the browser, not the server — curl and Postman are unaffected — and the request usually still reached the server.

Access-Control-Allow-Origin: https://app.site.com
Access-Control-Allow-Credentials: true      // cannot be used with Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization

A preflight OPTIONS request is sent first when the request isn't "simple" — i.e. it uses a method other than GET/HEAD/POST, a custom header like Authorization, or a JSON content type. The browser asks permission before sending the real request.

Fix a CORS error on the server, not in the client — there is nothing a frontend can do about it except use a dev proxy. If you see "has been blocked by CORS policy", the response is missing the right header.

Caching headers

  • Cache-Control: max-age=31536000, immutable — for hashed assets.
  • Cache-Control: no-cache — cache, but revalidate every time (via ETag).
  • ETag / If-None-Match → a 304 with no body when nothing changed.

25. Browser APIs Worth Knowing

IntersectionObserver

Tells you when an element enters or leaves the viewport — without a scroll handler. Use it for lazy loading, infinite scroll, scrollspy, and reveal-on-scroll animation.

const io = new IntersectionObserver((entries) => {
  entries.forEach(e => { if (e.isIntersecting) e.target.classList.add("visible"); });
}, { rootMargin: "0px 0px -20% 0px", threshold: 0.1 });
document.querySelectorAll(".reveal").forEach(el => io.observe(el));

Others

  • ResizeObserver — react to an element's size changing, not just the window's.
  • MutationObserver — watch for DOM changes (third-party widgets, rich text editors).
  • Web Workers — run heavy JS on another thread so the UI stays responsive. No DOM access; you communicate by postMessage.
  • Service Worker — a proxy between the page and the network: offline support, caching, push. The backbone of a PWA.
  • History API — pushState / popstate; this is how client-side routers work without reloading.
  • WebSocket — a persistent two-way connection for chat and live updates. EventSource (SSE) is simpler when you only need server→client.
  • Clipboard, Geolocation, Notification, matchMedia, Page Visibility — permission-gated but simple.
// Pause polling when the tab is hidden — free battery and quota
document.addEventListener("visibilitychange", () => {
  document.hidden ? stopPolling() : startPolling();
});

// Read a media query from JS, and react to changes
const mq = matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", e => setTheme(e.matches ? "dark" : "light"));
Part 5 — React

26. Mental Model & JSX

React's core idea: UI is a function of state. You never describe the steps to update the DOM — you describe what the UI should look like for the current state, and React works out the difference.

state change → your component function runs → returns a new element tree (plain objects) → React DIFFS it against the previous tree → applies the minimum DOM operations

JSX is just function calls

<div className="card">Hi</div>
// compiles to
React.createElement("div", { className: "card" }, "Hi")
// which produces a plain object:
{ type: "div", props: { className: "card", children: "Hi" } }

That's why components must return a single root (or a <>fragment</>), why attributes are camelCase (className, onClick, htmlFor), and why {} holds any JS expression — but not statements like if.

The virtual DOM and reconciliation

The "virtual DOM" is just that tree of objects. Comparing two objects is cheap; touching the real DOM is expensive. React compares old and new trees with two assumptions: elements of a different type produce a completely new subtree, and children are matched by key.

Keys

{items.map((item, i) => <Row key={item.id} ... />)}   // stable identity ✓
{items.map((item, i) => <Row key={i} ... />)}         // index — bug source ✗

With an index key, deleting the first item makes React think every row changed its content rather than that one row was removed — so component state (an input's text, a checkbox) sticks to the wrong row. Index keys are only safe for a static list that is never reordered, filtered or added to.

Changing a component's key is also a deliberate tool: it unmounts and remounts the component, resetting all of its state. Handy for resetting a form when the selected record changes.

27. State & Rendering

Props vs state

Props come from the parent and are read-only. State is owned by the component and changing it triggers a re-render. When two components need the same data, lift state up to their closest common parent.

State updates are asynchronous and batched

const [count, setCount] = useState(0);

setCount(count + 1);
setCount(count + 1);          // both read the SAME stale count → +1 total

setCount(c => c + 1);
setCount(c => c + 1);         // functional updates queue → +2 ✓

Use the functional form whenever the next value depends on the previous one.

Immutability is not optional

// ✗ React compares by reference — same object, no re-render
user.name = "New"; setUser(user);
items.push(newItem); setItems(items);

// ✓ new reference every time
setUser({ ...user, name: "New" });
setItems([...items, newItem]);
setItems(items.filter(i => i.id !== id));
setItems(items.map(i => i.id === id ? { ...i, done: true } : i));

// nested — spread at every level you change
setState(s => ({ ...s, address: { ...s.address, city: "Pune" } }));

When does a component re-render?

  1. Its own state changed.
  2. Its parent re-rendered (by default — children re-render regardless of props).
  3. A context it consumes changed.

Re-rendering is not the same as touching the DOM. React re-runs the function and diffs; if the output is identical, no DOM work happens. Don't optimise renders you haven't measured.

Derived state

// ✗ duplicated state that can drift out of sync
const [items, setItems] = useState([]);
const [count, setCount] = useState(0);

// ✓ just compute it during render
const count = items.length;
const visible = items.filter(i => !i.done);

28. Effects & Lifecycle

useEffect is for synchronising with something outside React — a network request, a subscription, a timer, the document title. It is not a general "run some code" hook.

useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);       // cleanup: unmount + before every re-run
}, []);                                  // deps
DepsRuns
omittedAfter every render
[]Once after mount (twice in dev StrictMode — deliberate)
[a, b]On mount and whenever a or b changes (Object.is comparison)

Fetching with cleanup

useEffect(() => {
  const ac = new AbortController();
  (async () => {
    try {
      const res = await fetch(`/api/users/${id}`, { signal: ac.signal });
      setUser(await res.json());
    } catch (e) { if (e.name !== "AbortError") setError(e); }
  })();
  return () => ac.abort();      // stops a stale response overwriting a newer one
}, [id]);

Without the cleanup, switching quickly between ids can let a slow first response land after the fast second one — a race condition that shows the wrong user.

Common mistakes

  • Missing deps — the effect closes over stale values. Let the lint rule guide you rather than trimming the array.
  • An object or function in deps — recreated each render, so the effect runs every time. Wrap it in useMemo/useCallback or move it inside the effect.
  • Setting state in an effect that the effect depends on — an infinite loop.
  • Using an effect to transform props into state — calculate it during render instead.
StrictMode double-invokes effects in development to surface missing cleanup. If your code breaks with it on, it has a real bug — don't disable StrictMode, fix the cleanup.

useLayoutEffect

Same API but runs synchronously after DOM mutation, before paint. Use it only when you must measure the DOM and adjust before the user sees anything (a tooltip position). It blocks paint, so it's the exception.

29. Hooks in Depth

The rules of hooks

Call hooks only at the top level of a component or another hook — never in a condition, loop or nested function. React tracks hooks by call order, so a conditional hook shifts every subsequent hook's state onto the wrong slot.

The core set

const [v, setV]   = useState(initial);      // or useState(() => expensive())
const [s, dispatch] = useReducer(reducer, init);  // complex/related transitions
const ref         = useRef(null);           // DOM node, or a mutable box that
                                            // survives renders WITHOUT re-rendering
const memo        = useMemo(() => heavy(a), [a]);   // cache a value
const cb          = useCallback(() => fn(a), [a]);  // cache a function identity
const theme       = useContext(ThemeContext);
const id          = useId();                // stable SSR-safe id for label/input

useRef has two jobs

const inputRef = useRef(null);              // 1. reach a DOM node
<input ref={inputRef} />;  inputRef.current.focus();

const renders = useRef(0);                  // 2. mutable value, no re-render
renders.current++;                          // changing .current never re-renders

useReducer when state moves together

function reducer(state, action) {
  switch (action.type) {
    case "loading": return { ...state, loading: true, error: null };
    case "success": return { loading: false, error: null, data: action.data };
    case "error":   return { loading: false, error: action.error, data: null };
    default: return state;
  }
}
const [state, dispatch] = useReducer(reducer, { loading: false, data: null, error: null });

Three useStates that always change together is the signal to switch — it makes impossible states (loading and error) unrepresentable.

Custom hooks

A custom hook is just a function starting with use that calls other hooks. It shares logic, not state — two components calling useFetch get independent state.

function useDebounced(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const t = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(t);
  }, [value, delay]);
  return debounced;
}

// in a component
const query = useDebounced(input);          // only fires a search when typing pauses
useEffect(() => { if (query) search(query); }, [query]);

30. Performance & Patterns

The memo trio

const Row = React.memo(function Row({ item, onSelect }) { ... });
// skips re-render when props are shallow-equal

const handleSelect = useCallback((id) => setSelected(id), []);
const sorted = useMemo(() => [...items].sort(byName), [items]);

React.memo alone is useless if you pass a fresh object or inline arrow as a prop — the shallow comparison fails every render. That's exactly why useCallback and useMemo exist: to keep prop identity stable.

Don't memoise everything. Memoisation costs memory and comparison time, and React 19's compiler does much of it automatically. Reach for it when a profiler shows a real problem: big lists, expensive computation, or a heavy child re-rendering constantly.

Composition beats memoisation

// Every state change re-renders <Expensive />
function App() {
  const [n, setN] = useState(0);
  return <><button onClick={() => setN(n+1)}>{n}</button><Expensive /></>;
}

// Move state down, or pass the child in as children — it no longer re-renders
function App({ children }) {
  const [n, setN] = useState(0);
  return <><button onClick={() => setN(n+1)}>{n}</button>{children}</>;
}

Code splitting

const Dashboard = lazy(() => import("./Dashboard"));
<Suspense fallback={<Spinner />}><Dashboard /></Suspense>

Split at route boundaries first, then at heavy components (charts, editors, maps).

Patterns worth naming

  • Container / presentational — separate data fetching from rendering.
  • Compound components — <Tabs><Tab/></Tabs> sharing state via context.
  • Render props / children as function — pass behaviour, let the caller render.
  • Error boundaries — a class component with componentDidCatch; still the only way to catch render errors.
  • Portals — createPortal(modal, document.body) escapes overflow and stacking contexts.
  • Virtualisation — render only visible rows for lists in the thousands.

31. Data, Routing & Forms

Context is for low-frequency global state

const ThemeContext = createContext("light");
<ThemeContext.Provider value={theme}><App /></ThemeContext.Provider>
const theme = useContext(ThemeContext);

Context solves prop drilling, not performance: every consumer re-renders when the value changes, and an inline object literal as value creates a new reference every render. Good for theme, locale, and the current user. Bad for fast-changing state.

Choosing a state tool

Kind of stateUse
Local UI (open, input)useState
Complex related transitionsuseReducer
Rarely-changing globalContext
Frequently-changing globalZustand / Redux Toolkit
Server dataTanStack Query / SWR / RTK Query
Shareable filters, tabs, pageThe URL
Server state is not client state. It's cached, it goes stale, it needs refetching, deduping and retries. Hand-rolling that in useEffect is where most React apps accumulate bugs — a query library gives you caching, background refresh, and loading/error states for free.

Routing

<Routes>
  <Route path="/" element={<Layout />}>
    <Route index element={<Home />} />
    <Route path="users/:id" element={<User />} />
    <Route path="*" element={<NotFound />} />
  </Route>
</Routes>

const { id } = useParams();
const [params, setParams] = useSearchParams();
const navigate = useNavigate();

Client-side routing uses the History API — no page reload, so state survives navigation. The server must be configured to return index.html for every path, or a refresh on /users/5 gives a 404.

CSR vs SSR vs SSG

RenderedGood forCost
CSRIn the browserDashboards behind a loginBlank first paint, weak SEO
SSROn the server per requestPersonalised, SEO-sensitive pagesServer load, TTFB
SSGAt build timeDocs, blogs, marketingRebuild to update
ISRBuilt, then revalidatedLarge mostly-static cataloguesSome staleness

Hydration is React attaching event listeners to server-rendered HTML. A "hydration mismatch" means the server and client rendered different markup — usually from Date.now(), Math.random(), or reading window during render.

Part 6 — Craft

32. Performance

Core Web Vitals

MetricMeasuresGoodMain fixes
LCP Largest Contentful PaintWhen the main content appears< 2.5sOptimise the hero image, preload it, cut render-blocking CSS/JS, faster TTFB
INP Interaction to Next PaintResponsiveness to input< 200msBreak up long tasks, less JS, debounce, use a worker
CLS Cumulative Layout ShiftUnexpected movement< 0.1width/height on images, reserve ad/embed space, font-display: swap + size-adjust

Loading strategy

  • Ship less JavaScript — the single biggest lever. Audit the bundle, drop heavy dependencies (moment → date-fns, lodash → native), code-split routes.
  • defer your scripts; preload the hero image and critical font; preconnect to the API origin.
  • Images — modern formats (AVIF/WebP), correct dimensions, srcset, lazy-load below the fold.
  • Fonts — subset, self-host, font-display: swap, and preload the one used above the fold.
  • Caching — hashed filenames with a long max-age; compress with Brotli.

Runtime performance

// Break a long task so the browser can paint and respond between chunks
async function processAll(items) {
  for (let i = 0; i < items.length; i++) {
    process(items[i]);
    if (i % 100 === 0) await new Promise(r => setTimeout(r, 0));
  }
}
  • Virtualise long lists; debounce/throttle high-frequency handlers; use { passive: true } on scroll.
  • Animate only transform and opacity.
  • Batch DOM reads and writes; prefer IntersectionObserver over scroll math.
Measure before optimising. Lighthouse for a lab score, the Performance panel for long tasks, the Network panel for waterfalls, and real-user (field) data for what users actually get. An optimisation you can't measure is a guess.

33. Accessibility

The four POUR principles: Perceivable, Operable, Understandable, Robust. In practice it comes down to a handful of habits.

The habits

  • Semantic HTML first. A native <button>, <a>, <input> or <dialog> gives you keyboard support, focus and screen-reader roles for free.
  • Keyboard — everything reachable with Tab, activatable with Enter/Space, dismissible with Escape. Never remove the focus ring without replacing it (:focus-visible).
  • Contrast — 4.5:1 for normal text, 3:1 for large text and UI borders.
  • Labels — every input has a real <label>; every icon-only button has aria-label.
  • Headings — one h1, no skipped levels; screen reader users navigate by heading.
  • Don't rely on colour alone to convey meaning — add an icon or text.

ARIA — the rules

No ARIA is better than bad ARIA. ARIA changes only how assistive tech announces something; it adds no behaviour. Use it when no native element fits.

<button aria-expanded="false" aria-controls="menu">Menu</button>
<div id="menu" hidden>…</div>

<div role="alert">Saved</div>                    <!-- interrupts immediately -->
<div aria-live="polite">3 results</div>          <!-- announced when idle -->
<button aria-label="Close">×</button>
<span aria-hidden="true">🔥</span>                <!-- decorative -->

Modals — the checklist interviewers love

  1. Move focus into the dialog when it opens.
  2. Trap focus inside while it's open.
  3. Close on Escape and on backdrop click.
  4. Return focus to the trigger element on close.
  5. role="dialog" aria-modal="true" and a labelled title — or just use <dialog>, which does most of this natively.

Test by unplugging your mouse, then with a real screen reader (VoiceOver on Mac: ⌘F5). Automated tools like axe catch roughly a third of issues — the rest is manual.

34. Frontend Security

XSS — Cross-Site Scripting

Attacker-controlled data ends up executed as code in your page. The consequence is total: read localStorage, make requests as the user, rewrite the page.

// ✗ any of these with untrusted input
el.innerHTML = comment;
<div dangerouslySetInnerHTML={{ __html: comment }} />
eval(input);  new Function(input);  <a href={userUrl}>   // javascript: URLs

// ✓
el.textContent = comment;                    // escapes automatically
el.innerHTML = DOMPurify.sanitize(comment);  // when you must render HTML

React escapes interpolated values by default, which prevents most XSS — dangerouslySetInnerHTML is named that way for a reason. Add a Content-Security-Policy header as defence in depth.

CSRF

A malicious site makes the user's browser send an authenticated request to yours, using cookies that are attached automatically. Defences: SameSite=Lax/Strict cookies, a CSRF token, and never using GET for state changes.

Other essentials

  • No secrets in frontend code. Every env var in the bundle is public — VITE_/NEXT_PUBLIC_ prefixes exist to make that explicit.
  • Never trust the client. Hiding an admin button is UX; the server must still check permissions.
  • rel="noopener noreferrer" on target="_blank" links so the opened page can't touch window.opener.
  • Dependencies — npm audit, lockfiles, and scepticism about adding packages.
  • HTTPS everywhere, plus Strict-Transport-Security, X-Content-Type-Options: nosniff and a sensible frame-ancestors to prevent clickjacking.

35. Testing

LevelTestsTools
UnitOne function or hook in isolationVitest, Jest
ComponentRendering and interactionReact Testing Library
IntegrationSeveral units together, mocked networkRTL + MSW
E2EA real browser, a real flowPlaywright, Cypress
test("submits the entered email", async () => {
  const onSubmit = vi.fn();
  render(<SignupForm onSubmit={onSubmit} />);

  await userEvent.type(screen.getByLabelText(/email/i), "a@b.com");
  await userEvent.click(screen.getByRole("button", { name: /sign up/i }));

  expect(onSubmit).toHaveBeenCalledWith({ email: "a@b.com" });
});
The Testing Library principle: query the way a user finds things — by role, label and visible text — not by class name or test id. A test that breaks when you rename a CSS class is testing the wrong thing; a test that survives a refactor but catches a real regression is worth keeping.
  • Prefer getByRole → getByLabelText → getByText; getByTestId is the last resort.
  • Mock the network (MSW), not your own modules.
  • Test behaviour and output, not internal state or implementation details.
  • A small number of E2E tests covering the critical paths (signup, checkout) beats a large number of brittle ones.
Revision

Interview Q&A — quick answers

HTML & CSS

  • Why semantic HTML? Accessibility, SEO and free default behaviour — the element describes the meaning, not the appearance.
  • Explain the box model. content → padding → border → margin; border-box makes width include padding and border.
  • Specificity order? inline > id > class/attribute/pseudo-class > element; ties break on source order; !important overrides the lot.
  • Flexbox vs Grid? Flex is one-dimensional and content-driven; Grid is two-dimensional and layout-driven.
  • flex: 1 vs flex: auto? 1 means basis 0 → equal widths; auto means basis auto → content-sized, then grows.
  • Centre a div? display:grid; place-items:center; on the parent.
  • display:none vs visibility:hidden vs opacity:0? Removed from layout / keeps space, not interactive / keeps space, still clickable.
  • position sticky? Relative until a scroll threshold, then fixed — but only within its parent's box.
  • rem vs em? rem is relative to the root; em to the element's own font-size and compounds when nested.

JavaScript

  • == vs ===? Loose equality coerces types; strict doesn't. Always use ===.
  • What is a closure? A function that retains access to its defining scope after that scope has returned. Powers private state, debounce, throttle, module pattern.
  • var vs let vs const? Function-scoped and hoisted as undefined vs block-scoped with a TDZ; const can't be reassigned but its contents can change.
  • How does this work? Determined by the call site: new → the instance, explicit bind/call/apply → that object, method call → the object before the dot, plain call → undefined/window. Arrow functions inherit lexically.
  • Explain the event loop. Stack → drain all microtasks → render → one macrotask → repeat. Promises are microtasks; setTimeout is a macrotask.
  • Output of console.log(1); setTimeout(...0); Promise.then(...); console.log(2)? 1, 2, promise, timeout.
  • Prototypal inheritance? Objects link to other objects; property lookup walks the chain. Classes are syntax over it.
  • Promise.all vs allSettled? all rejects on the first failure; allSettled always resolves with each outcome.
  • Debounce vs throttle? Debounce runs after the activity stops; throttle runs at most once per interval.
  • Event delegation? One listener on a parent using e.target.closest() — fewer listeners, works for dynamic children.
  • Shallow vs deep copy? Spread copies one level; structuredClone goes all the way down.

React

  • Virtual DOM? A lightweight object tree; React diffs old against new and applies the minimum DOM changes.
  • Why keys? Stable identity during reconciliation. Index keys break on reorder or deletion and can attach state to the wrong row.
  • State vs props? State is owned and mutable via setter; props come from the parent and are read-only.
  • Why is my state not updating immediately? Updates are batched and asynchronous — use the functional form setX(x => …).
  • useEffect dependency array? Omitted → every render; [] → once on mount; [a] → when a changes. Always return cleanup for subscriptions and timers.
  • useMemo vs useCallback? Cache a computed value vs cache a function's identity. useCallback(fn, d) ≡ useMemo(() => fn, d).
  • When does a component re-render? Own state changed, parent re-rendered, or a consumed context changed.
  • Context vs Redux? Context passes values down without drilling (all consumers re-render); Redux/Zustand is for frequently-changing global state with selectors and devtools.
  • Controlled vs uncontrolled input? Value in React state vs value in the DOM read via a ref.
  • Custom hook? A use… function composing other hooks; shares logic, not state.
  • Why does my effect run twice? StrictMode in development, deliberately — to expose missing cleanup.

Browser & general

  • localStorage vs sessionStorage vs cookies? Persistent / per-tab / sent with every request and configurable via flags.
  • What is CORS? A browser-enforced rule; the server opts in with Access-Control-Allow-* headers. A preflight OPTIONS precedes non-simple requests.
  • defer vs async? Both download in parallel; defer executes after parsing in order, async executes as soon as it lands, out of order.
  • Reflow vs repaint? Geometry recalculated vs pixels redrawn. Composite-only (transform/opacity) is cheapest.
  • How do you prevent XSS? textContent over innerHTML, sanitise any HTML you must render, add a CSP, keep tokens in HttpOnly cookies.
  • Improve page load? Ship less JS, code-split, optimise images and fonts, cache with hashed filenames, defer non-critical work — then measure.
  • Core Web Vitals? LCP < 2.5s, INP < 200ms, CLS < 0.1.

Learning Path

  1. HTML & CSS properly — semantics, the box model, the cascade. Build three static pages from a design without a framework.
  2. Layout — Flexbox and Grid until you stop guessing. Rebuild a real site's layout from scratch.
  3. Responsive — mobile-first, clamp(), container queries, and testing at 200% zoom.
  4. JavaScript fundamentals — types, scope, closures, this, prototypes, array methods. Don't skip to a framework here; everything later depends on it.
  5. Async JS — the event loop, promises, async/await, error handling, cancellation.
  6. The DOM — build a to-do app and an image carousel with no library at all, once.
  7. Fetch & APIs — loading, error and empty states; CORS; a real REST integration.
  8. Tooling — npm, Vite, ESLint, Prettier, Git. Learn what a bundler actually does.
  9. React — components, state, effects, hooks, lists and forms. Then routing and a data-fetching library.
  10. Project 1 — a complete CRUD app with auth, routing, forms with validation, and proper loading/error states.
  11. Quality — accessibility audit, Lighthouse pass, and tests for the critical paths.
  12. Project 2 — something with real complexity: optimistic updates, infinite scroll, a virtualised table, or realtime via WebSocket.
  13. Level up — TypeScript everywhere, then SSR with Next.js, then one deep area (animation, data viz, or design systems).
The single best habit: for every component you build, ask "what does this look like while loading, when the request fails, when the list is empty, when the text is three times longer, and when I only have a keyboard?" Answering those five turns tutorial code into frontend engineering.