The mental models behind modern frontend

Not a syntax reference — the internet has plenty. This is the layer underneath: why the type system behaves the way it does, what React is actually doing when it re-renders, and how a package manager decides what ends up in node_modules. Each section ends with questions that are hard if you only know the syntax.

Foundations CSS & the platform TypeScript React Server state Component design Packages & lifecycle

Foundations

The three machines you are programming

Almost every confusing frontend bug becomes obvious once you know which of these three machines you are actually talking to: the browser's rendering engine, the JavaScript event loop, or your build pipeline. TypeScript and React each live in exactly one of them.

01 The browser render pipeline

HTML and CSS are parsed into two trees, combined, measured, and painted. Every visual change re-enters this pipeline at some stage.

HTML  → DOM tree     \
                        → Render tree → Layout → Paint → Composite
CSS   → CSSOM tree   /     (style)     (reflow)  (repaint)  (GPU)
  • Layout / reflow computes geometry. Triggered by changes to width, height, position, font-size, or by reading a layout property (offsetHeight, getBoundingClientRect()) after a write.
  • Paint fills pixels. Triggered by color, shadow, border-radius.
  • Composite only moves existing layers around. transform and opacity can skip layout and paint entirely — this is why CSS animations should use them.
Layout thrashing: a loop that writes a style then reads a layout property forces a synchronous reflow on every iteration. Batch all reads, then all writes.

Touching the DOM by hand does not scale: every place that changes data also has to remember every place on screen that depends on it. React's answer is the next card.

02 Declarative rendering and the virtual DOM

A todo list has a counter ("3 items left") and an empty-state message. Both depend on the same data as the list itself.

// imperative: YOU are responsible for every screen effect of every change
function addTodo(text) {
  todos.push({ text, done: false });
  listEl.appendChild(makeRow(text));     // update the DOM
  counterEl.textContent = countLeft();   // remember the counter
  emptyMsgEl.style.display = 'none';     // remember the empty message
}
function completeTodo(id) {
  todos.find(t => t.id === id).done = true;
  rowEl.classList.add('done');
  counterEl.textContent = countLeft();   // repeat it here too
  // ...and here, if this was the last incomplete item?
}

addTodo, completeTodo, deleteTodo, filterTodos — every one of them needs the same three lines. Miss one, in one function, and the screen lies: the counter says 3 while the list shows 2. Nothing crashes; it is just wrong.

// declarative: describe the screen as a function of the data, forget nothing
function TodoApp({ todos }) {
  const left = todos.filter(t => !t.done).length;
  return (
    <>
      {todos.length === 0 && <p>No todos</p>}
      <p>{left} items left</p>
      <ul>{todos.map(t => <li className={t.done ? 'done' : ''}>{t.text}</li>)}</ul>
    </>
  );
}

There is no addTodo function that touches the DOM. You change todos, and this function re-runs. The counter and the empty message are not updated — they are recomputed every time, so they cannot fall out of sync. There is no step to forget.

That function does not return real DOM nodes. It returns a plain JS object tree describing what the DOM should look like:

{ type: 'ul', children: [
  { type: 'li', props: { className: 'done' }, children: ['Buy milk'] },
  { type: 'li', props: { className: ''     }, children: ['Walk dog'] },
]}

That object tree is the virtual DOM — "virtual" because it is just data: cheap to create, cheap to throw away, nothing on screen yet. When a checkbox changes, React re-runs the function, gets a second tree, and diffs it against the previous one. Here that finds exactly one li whose className changed, and converts that into one real instruction: set this element's class. The rest of the tree is left alone.

Two separate ideas, often fused into one: the component model (write a function of your data; nothing to forget) is what lets you describe target state. The virtual DOM diff is the mechanism that makes doing so cheap — comparing two lightweight JS trees instead of rebuilding or directly inspecting the live DOM on every change. Rebuilding real DOM nodes from scratch on every render would also reset scroll position, input focus, and text selection, which the diff avoids by only touching what actually changed.
"Minimal" is doing a lot of work in that sentence. React's diff is not optimal — true minimal tree-diffing is too expensive to run on every keystroke. React uses two cheap heuristics instead: a different component type at the same position means throw away and rebuild that subtree rather than diff it, and key tells React which children are the same item across renders. When those heuristics are right the diff is small; when they're wrong (index keys on a reordered list, see card 17) React computes a needlessly large one. It is also worth knowing the React team no longer markets "virtual DOM" as the headline feature — it's the cost React pays to make the declarative model fast, not the reason the model is good. Solid and Svelte get the same "describe state, forget nothing" model without a virtual DOM at all, by compiling each component to code that updates the exact DOM nodes directly.

03 One thread, two queues

Your JavaScript, the browser's layout work, and paint all share a single main thread. A 200 ms function is a 200 ms frozen UI.

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

The loop runs one macrotask, then drains the entire microtask queue, then may render a frame. Microtasks that schedule more microtasks can starve rendering forever; setTimeout always yields.

This is the substrate under React's concurrent features. startTransition and time-slicing exist so React can yield the thread back to the browser mid-render instead of blocking a frame.

04 Source is not what ships

Nothing you write reaches the browser unchanged. Knowing each stage tells you where to look when something breaks.

StageWhat happensTypical tool
Type checkVerifies types, emits nothingtsc --noEmit
TranspileErases types, downlevels syntax, compiles JSXesbuild, SWC, Babel
Resolve & bundleWalks the import graph, inlines modulesRollup, Rspack, webpack
Tree-shakeDrops statically-unreachable ESM exportsRollup / bundler
Split & minifyChunks per route, renames identifiersBundler + terser/esbuild
The single most useful fact about TypeScript: types are erased. There is zero type information at runtime. A value from fetch() typed as User is a promise, not a guarantee — validate at the boundary (Zod, Valibot) if it matters.
Consequence: tree-shaking only works on ESM. A dependency published as CommonJS with dynamic require() cannot be statically analysed, so all of it ships. This is why the exports field and dual-format packages matter — see the package section.

CSS & the platform

How a declaration wins

Most CSS frustration is a misunderstanding of the resolution order. It is not "the more specific rule wins" — specificity is only the third tiebreaker, and it is compared component-wise, not summed.

05 The cascade, in actual order

1. Origin & importance    user-agent < author < author !important < user !important
2. Cascade layers         a later @layer beats an earlier one, at ANY specificity
                          (!important reverses the layer order)
3. Specificity            compared as a tuple, left to right
4. Source order           last one wins

Specificity is the triple (ids, classes, elements). Because the comparison is left-to-right, no number of classes ever beats a single id:

#app p                              /* (1,0,1) */
.card.featured.large.active.dark p  /* (0,5,1) */  — still loses. Always.

:where(#app) .card p                /* (0,1,1) */  — :where() zeroes its argument
:is(#app, .card) p                  /* (1,0,1) */  — :is() takes the HIGHEST of its args

Cascade layers are the modern answer to specificity wars, and the reason to stop reaching for !important:

@layer reset, base, components, utilities;

@layer components { #sidebar .btn { padding: 1rem } }   /* (1,1,0) */
@layer utilities  { .p-0 { padding: 0 } }               /* (0,1,0) — but WINS */
Declare your layer order once at the top of your stylesheet. Third-party CSS can be wrapped on import — @import url(vendor.css) layer(vendor) — which permanently subordinates it to your own styles without touching a single selector.
Inheritance is separate from the cascade. Only some properties inherit (color, font-*, line-height, visibility, cursor); box-model properties do not. When nothing matches an element, an inheritable property takes the parent's computed value. revert and revert-layer are the underused keywords here — they roll back to the previous origin or layer rather than nuking to initial.

06 The two invisible trees: stacking & containing blocks

"Why doesn't z-index: 9999 work?" and "why is my fixed modal trapped inside a card?" are the same class of bug: an ancestor silently created a context you did not know about.

Stacking contexts. z-index only orders siblings within one stacking context. If your element sits in a context whose root has z-index: 1, no value inside it can escape past a sibling context at z-index: 2.

Creates a new stacking context:
  the root element
  position: relative|absolute  WITH a z-index other than auto
  position: fixed | sticky     (always)
  opacity less than 1
  transform, filter, backdrop-filter, perspective, clip-path, mask
  will-change on any of the above
  isolation: isolate           ← the explicit, intentional way
  contain: paint | layout | strict
  mix-blend-mode other than normal

Containing blocks. This is what percentages and absolute positioning resolve against. position: absolute looks for the nearest ancestor with position other than static — but position: fixed normally resolves against the viewport, unless an ancestor has transform, filter, backdrop-filter, contain: layout|paint, or will-change on those.

The classic production bug: a modal with position: fixed; inset: 0 renders correctly until someone adds a hover transform: translateY(-2px) to the card containing it. The transform makes that card the containing block, and the modal is now clipped inside a 300px box. This is the real reason UI libraries render overlays through a portal to document.body — it is not about z-index, it is about escaping the containing block.
Debug it by walking up the DOM in DevTools looking for transform, filter, opacity, and will-change on ancestors. Chrome's Layers panel visualises the stacking contexts directly.

07 Intrinsic sizing, flex, and grid

Flexbox distributes space along one axis. Grid places items on two. The confusion is almost always about sizing, not placement.

flex: <grow> <shrink> <basis>

flex: 0 1 auto   /* the default: don't grow, may shrink, size to content */
flex: 1          /* = 1 1 0%   — ignore content, split space EQUALLY   */
flex: auto       /* = 1 1 auto — grow from content size, PROPORTIONALLY */

That flex: 1 vs flex: auto distinction is why a row of "equal" columns is subtly unequal: with auto, the column with more text starts larger and stays larger.

The single most common flexbox bug. A flex item's min-width defaults to auto, meaning it refuses to shrink below its content's min-content size. So a long unbroken string blows out the layout instead of truncating, and text-overflow: ellipsis appears to do nothing.
.row  { display: flex; }
.cell { min-width: 0; }        /* <- the fix. (min-height: 0 in a column) */
.cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

Grid. fr is a fraction of leftover space after fixed tracks and gaps are subtracted — it is not a percentage. The responsive-without-media-queries idiom:

.cards {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
}
/* auto-fit  — collapses empty tracks, so few items stretch to fill
   auto-fill — keeps empty tracks, so few items stay their minimum width
   min(18rem, 100%) — prevents overflow on screens narrower than 18rem   */

Four modern features that replace whole categories of JavaScript:

  • @container (min-width: 30rem) — style by parent width, not viewport. This is what actually makes components reusable across layouts.
  • :has() — a real parent selector. .card:has(img), form:has(:invalid) button.
  • clamp(1rem, 2.5vw, 1.5rem) — fluid type and spacing with no breakpoints.
  • Logical properties (margin-inline, padding-block, inset) — correct by construction in RTL locales.

CSS & the platform

Semantics and the accessibility tree

The browser builds a second tree from your DOM. Screen readers, voice control, browser extensions, and your own tests read that tree — not your JSX. Semantic HTML populates it for free; a div populates nothing.

08 Role, name, state

Every node in the accessibility tree has a role (what it is), an accessible name (what it is called), and state (checked, expanded, disabled, invalid). If any of the three is missing, the control is broken for someone.

<div class="btn" onClick={save}>Save</div>

Missing, all at once:
  • not in the tab order              (no keyboard focus)
  • no Enter / Space activation       (you must implement both)
  • role is 'generic'                 (announced as nothing)
  • no disabled state                 (aria-disabled must be wired by hand)
  • no form submission behaviour
  • no focus ring, no :active, no forced-colors support

<button onClick={save}>Save</button>   — all six, for free.

The rules of ARIA, in priority order:

  1. Don't use ARIA if a native element does the job. A <button> beats role="button" every time.
  2. Don't override native semantics (<h2 role="tab"> throws away the heading).
  3. Every interactive ARIA control must be fully keyboard operable — ARIA changes announcements, never behaviour.
  4. Never put role="presentation" or aria-hidden="true" on a focusable element. That creates a control a screen reader user can focus but cannot perceive.
  5. Every interactive element needs an accessible name. Icon-only buttons need aria-label.
Where accessible names come from, in order: aria-labelledbyaria-label → native source (a <label for>, the button's text content, an image's alt) → title. Placeholder text is not a label — it disappears when the user types and is invisible to many tools.

09 Focus is application state

In a server-rendered site the browser manages focus on navigation. In an SPA, nothing does — unless you do. This is the most-skipped responsibility in frontend work.

  • Route changes: the browser does not move focus or announce anything. A screen reader user clicks a link and stays exactly where they were. Move focus to the new page's <h1> (with tabindex="-1") or a skip target, and announce the change in a live region.
  • Dialogs: focus moves in on open, is trapped while open, and returns to the trigger on close. Escape closes. <dialog> with showModal() gives you the trap and the inert background natively.
  • Never outline: none without a replacement. Style :focus-visible instead — it shows a ring for keyboard users and suppresses it for mouse clicks, which is the actual thing designers are asking for.
  • Tab order follows DOM order. A positive tabindex is nearly always a bug; tabindex="-1" means "focusable by script, not by Tab". If the visual order and DOM order disagree (because of order or grid-area), keyboard users get a scrambled path — fix the DOM, not the tabindex.
  • Async updates need aria-live="polite" (or role="status") on a container that exists before the content arrives. Injecting a live region and its content at the same time announces nothing.
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
:focus:not(:focus-visible) { outline: none; }
The cheapest feedback loop that exists: write tests with Testing Library's getByRole('button', { name: 'Save' }). Those queries read the accessibility tree. If your test cannot find the element by role and name, a screen reader cannot either — so your test suite becomes an accessibility linter you were going to write anyway. Add eslint-plugin-jsx-a11y and an axe check in CI and you have covered most of the mechanical failures.

Check yourself — CSS & the platform

Q1 Which rule applies the padding?

#app p                              { padding: 2rem }
.card.featured.large.active.dark p  { padding: 0 }
Specificity is the tuple (ids, classes, elements) compared left to right, not a weighted sum. (1,0,1) beats (0,5,1) at the first component and the rest is never examined. Source order only breaks ties between equal specificity. This is also why the fix is a cascade layer or removing the id from the selector — not adding a sixth class.

Q2 Your modal is position: fixed; inset: 0 but renders clipped inside a card. Someone recently added transform: translateY(-2px) to that card's hover state. What happened?

transform, filter, backdrop-filter, perspective, contain, and will-change on any ancestor make that ancestor the containing block for position: fixed descendants. This is the real reason overlays are rendered through a portal to document.body — z-index is the symptom people notice, but escaping the containing block is the actual requirement.

Q3 A flex row contains a long unbroken string. text-overflow: ellipsis is set but the text blows out the layout instead of truncating. Why?

The automatic minimum size of a flex item is its min-content size, which for an unbreakable string is its full width. The item never gets small enough for overflow — and therefore ellipsis — to apply. Set min-width: 0 on the flex item (min-height: 0 in a column direction). Same root cause behind grid items overflowing their tracks.

Q4 You replace <div class="btn" onClick={save}> with <div class="btn" role="button" onClick={save}>. Is it accessible now?

ARIA changes how something is announced; it never adds behaviour. You have now promised a button and delivered a div — a screen reader user is told to press it, and nothing happens. A faithful replacement needs role, tabindex="0", keydown handlers for both Enter and Space (with preventDefault on Space to stop scrolling), aria-disabled wiring, and focus styles. Rule 1 of ARIA exists because <button> is all of that already.

TypeScript

Structural typing

TypeScript compares types by shape, never by name. This one rule explains excess property checks, why interface and type are mostly interchangeable, and why you sometimes need to fake nominal typing.

10 Shape, not name

interface Point { x: number; y: number }
interface Vec2  { x: number; y: number }

const p: Point = { x: 1, y: 2 };
const v: Vec2  = p;            // OK — identical shapes, unrelated declarations

interface Point3 extends Point { z: number }
const q: Point = { x: 1, y: 2, z: 3 } as Point3;  // OK — extra members are fine

A type is assignable to another if it has at least the required members. Extra members are normally invisible.

Except for object literals assigned directly to a typed target, where TypeScript adds an excess property check to catch typos:

const a: Point = { x: 1, y: 2, z: 3 };  // Error: 'z' does not exist in type 'Point'

const raw = { x: 1, y: 2, z: 3 };
const b: Point = raw;                   // OK — 'raw' is not a fresh literal
Why this trips people up: the check is a heuristic on freshness, not a real type rule. Assign through a variable and it disappears. Spread the object and it disappears. It is a linting nicety layered on top of structural assignability.

11 Branding: faking nominal types

When two things are both string but must never be swapped — a UserId and an OrgId, cents and dollars — attach a phantom property.

declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };

type UserId = Brand<string, 'UserId'>;
type OrgId  = Brand<string, 'OrgId'>;

const asUserId = (s: string) => s as UserId;   // one sanctioned cast

function loadUser(id: UserId) { /* ... */ }

loadUser('u_123');            // Error
loadUser(asUserId('u_123'));  // OK
declare const org: OrgId;
loadUser(org);                // Error — brands differ
Zero runtime cost: the brand only exists in the type system. The value is still a plain string.

12 type vs interface

interfacetype
Object shapesYesYes
Unions, tuples, primitivesNoYes
Mapped / conditional typesNoYes
Declaration mergingYes (reopens)No (duplicate error)
Implicit index signatureNoYes

Practical rule: use interface for public object contracts you might augment (and for library types others extend), type for everything else. Declaration merging is the reason interface wins for module augmentation:

declare module 'react' {
  interface CSSProperties { [key: `--${string}`]: string | number }
}
Real bug this causes: a type Foo = { a: string } is assignable to Record<string, unknown>, but an interface Foo is not — interfaces lack an implicit index signature because they can be merged later.

TypeScript

Unions and narrowing

Control-flow analysis is TypeScript's best feature. Model your state as a union of legal shapes and the compiler will refuse to let you render impossible UI.

13 Discriminated unions & exhaustiveness

The classic frontend smell is { loading: boolean; error?: Error; data?: T } — which permits loading: true and an error and data simultaneously. Make illegal states unrepresentable.

type Query<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error';   error: Error }
  | { status: 'success'; data: T };

function view(q: Query<User[]>) {
  switch (q.status) {
    case 'idle':    return null;
    case 'loading': return 'Loading…';
    case 'error':   return q.error.message;      // narrowed: error exists
    case 'success': return q.data.length;        // narrowed: data exists
    default: {
      const _never: never = q;   // compile error the day you add a 5th state
      return _never;
    }
  }
}
That never assignment is the highest-value four lines in a TypeScript codebase. It turns "I added a state and forgot a branch" from a production bug into a build failure.

14 Every way to narrow

typeof v === 'string'          // primitives
v instanceof Error             // classes
'data' in v                    // property presence
v.kind === 'circle'            // literal discriminant
Array.isArray(v)               // built-in guard
if (v) { }                     // truthiness (careful with 0 and '')
v !== null && v !== undefined  // nullish

// user-defined type predicate
function isUser(v: unknown): v is User {
  return typeof v === 'object' && v !== null && 'id' in v;
}

// assertion function — narrows for the rest of the scope
function assertDefined<T>(v: T): asserts v is NonNullable<T> {
  if (v == null) throw new Error('expected a value');
}
Narrowing is not permanent. It is discarded across an await, inside a callback that TypeScript cannot prove runs synchronously, and after any function call that could mutate the object. Copy the narrowed value into a const first.
if (user.profile) {
  setTimeout(() => console.log(user.profile.name), 0); // Error: possibly undefined
}
const profile = user.profile;
if (profile) {
  setTimeout(() => console.log(profile.name), 0);      // OK — const cannot change
}

15 unknown, any, never

  • any — disables checking in both directions. It is contagious: one any silently erases type safety across everything it touches.
  • unknown — the honest top type. Everything is assignable to it; it is assignable to nothing until you narrow. Correct type for JSON.parse, catch bindings, and untrusted input.
  • never — the bottom type. No value has it. Produced by exhaustive checks, impossible intersections, and functions that never return.
try { risky(); }
catch (e) {            // 'unknown' under useUnknownInCatchVariables
  if (e instanceof Error) console.error(e.message);
  else console.error(String(e));
}
Enable these tsconfig flags or the type system is mostly decorative: strict, plus noUncheckedIndexedAccess (makes arr[0] return T | undefined, which is the truth) and exactOptionalPropertyTypes.

TypeScript

Generics as type-level functions

A generic is a function whose arguments are types. K extends keyof T is a parameter with a constraint; T[K] is the return expression. Once you read them that way, the advanced type features stop looking like magic.

16 Constraints preserve relationships

function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: 'ada', admin: true };
const n = pluck(user, 'name');   // string  — not string | number | boolean
pluck(user, 'nope');             // Error: not assignable to 'id' | 'name' | 'admin'

Compare with the non-generic version, which throws the relationship away:

function pluckBad(obj: object, key: string): unknown { /* caller learns nothing */ }
Rule of thumb: a type parameter earns its place only if it appears at least twice — once in the input, once in the output or a constraint. A parameter used once is just any with extra steps.

17 The four type-level operators

Nearly every "wizard" type in a library is built from these four.

const config = { host: 'localhost', port: 5173, secure: false };

// 1. typeof / keyof / indexed access — move between values and types
type Config = typeof config;      // { host: string; port: number; secure: boolean }
type Key    = keyof Config;       // 'host' | 'port' | 'secure'
type Port   = Config['port'];     // number

// 2. Mapped types — iterate over keys, with modifiers and key remapping
type Nullable<T>  = { [K in keyof T]: T[K] | null };
type Mutable<T>   = { -readonly [K in keyof T]-?: T[K] };
type Getters<T>   = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] };
// Getters<Config> = { getHost(): string; getPort(): number; getSecure(): boolean }

// 3. Conditional types + infer — pattern-match a type apart
type Unwrap<T>    = T extends Promise<infer U> ? U : T;
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;

// 4. Template literal types — string arithmetic
type EventName<T extends string> = `on${Capitalize<T>}`;   // 'click' -> 'onClick'
Distributivity: a conditional type with a naked type parameter distributes over unions, applying itself to each member separately.
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>;   // string[] | number[]   <- distributed

type ToArray2<T> = [T] extends [unknown] ? T[] : never;
type B = ToArray2<string | number>;  // (string | number)[]   <- wrapped, no distribution
Wrapping in a tuple is the standard trick to switch it off.

18 Utility types and satisfies

TypeDoes
Partial<T> / Required<T>Toggle ? on every property
Pick<T,K> / Omit<T,K>Select or drop keys (Omit does not check that K exists)
Record<K,V>Build a dictionary type
Exclude<U,X> / Extract<U,X>Filter members out of a union
ReturnType<F> / Parameters<F>Pull a function apart
Awaited<P>Recursively unwrap promises
NoInfer<T>Exclude a position from inference (TS 5.4+)

satisfies checks a value against a type without widening it. This is the difference between validating and destroying your literal types:

// annotation: checked, but widened — you lose the literals
const routesA: Record<string, string> = { home: '/', user: '/users/:id' };
routesA.typo;              // no error — index signature allows anything
// routesA.home is 'string'

// satisfies: checked AND narrow
const routesB = {
  home: '/',
  user: '/users/:id',
} satisfies Record<string, `/${string}`>;

routesB.typo;              // Error — key does not exist
type Home = typeof routesB.home;   // '/'  — literal preserved

TypeScript

TypeScript inside React

A short list of patterns that cover roughly 90% of the typing you will do in a React codebase.

19 Component prop patterns

// 1. Extend a DOM element instead of re-declaring its props
type ButtonProps = React.ComponentPropsWithoutRef<'button'> & {
  variant?: 'primary' | 'ghost';
};
function Button({ variant = 'primary', ...rest }: ButtonProps) {
  return <button data-variant={variant} {...rest} />;
}

// 2. Borrow another component's props
type IconProps = React.ComponentProps<typeof Icon>;

// 3. Generic component (note the trailing comma — required in .tsx)
function List<T,>({ items, renderItem }: {
  items: readonly T[];
  renderItem: (item: T, index: number) => React.ReactNode;
}) {
  return <ul>{items.map((item, i) => <li key={i}>{renderItem(item, i)}</li>)}</ul>;
}

// 4. Mutually exclusive props — a union, enforced at the call site
type LinkOrButton =
  | { href: string; onClick?: never }
  | { href?: never; onClick: () => void };
Avoid React.FC. It adds nothing over annotating the props parameter, historically injected an implicit children, and makes generic components awkward. Just type the parameter.

20 Typing the hooks

// useState: inference is usually right; be explicit when the initial value lies
const [user, setUser] = useState<User | null>(null);
const [tab, setTab]   = useState<'edit' | 'preview'>('edit');  // else widened to string

// useRef has two distinct meanings, and two distinct types
const inputRef = useRef<HTMLInputElement>(null);  // DOM ref: RefObject, .current readonly-ish
const timerRef = useRef<number | undefined>(undefined); // mutable box: MutableRefObject

// useReducer: type the state and the action union, and the rest infers
type Action = { type: 'inc' } | { type: 'set'; value: number };
function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'inc': return state + 1;
    case 'set': return action.value;
  }
}
const [count, dispatch] = useReducer(reducer, 0);

// Events: let the handler position infer, don't annotate by hand
<input onChange={(e) => setName(e.target.value)} />   // e is inferred correctly
// standalone handler needs the explicit type:
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value);
In React 19, ref is an ordinary prop on function components — forwardRef is no longer needed for new code. Type it as ref?: React.Ref<HTMLInputElement> in your props.

Check yourself — TypeScript

Q5 Which of these lines errors?

interface Point { x: number; y: number }
const raw = { x: 1, y: 2, z: 3 };
const a: Point = raw;                    // A
const b: Point = { x: 1, y: 2, z: 3 };   // B
Structural typing says extra properties are fine, so A is legal. B is a fresh object literal assigned to a typed target, which triggers excess property checking — a deliberate heuristic to catch typos, not a consequence of assignability.

Q6 What is R?

type ToArray<T> = T extends unknown ? T[] : never;
type R = ToArray<string | number>;
A naked type parameter in a conditional type distributes over unions: the conditional is applied to string and number separately, then the results are unioned. Write [T] extends [unknown] to suppress distribution and get (string | number)[].

Q7 What is the type of routes.home?

const routes = {
  home: '/',
  user: '/users/:id',
} satisfies Record<string, string>;
satisfies validates the value against the type but leaves the value's own inferred type intact, so the literals survive. An annotation (const routes: Record<string,string> = …) would widen every value to string and add an index signature that hides typos.

Q8 Why does the second line error, and the fourth not?

if (user.profile) {
  setTimeout(() => console.log(user.profile.name));   // Error
}
const p = user.profile;
if (p) {
  setTimeout(() => console.log(p.name));              // OK
}
TypeScript cannot prove the callback runs before something reassigns user.profile, so it discards the narrowing at the function boundary. Copying into a const gives it an immutable binding it can safely keep narrowed. The same happens across await and around calls that might mutate.

Q9 Under strict, what is the type of first?

declare const names: string[];
const first = names[0];
strict does not include noUncheckedIndexedAccess. By default TypeScript optimistically types every index access as T, which is a real hole — indexing past the end returns undefined at runtime. Turning the flag on is one of the highest-value tsconfig changes you can make to an existing codebase, and one of the noisiest.

React

Render, reconcile, commit

React has exactly one job: keep the DOM in sync with a value you return from a function. Everything else — hooks, keys, memo, Suspense — is machinery around that. Understanding the three phases resolves most "why did this re-render / why did my state disappear" questions.

21 The three phases

1. RENDER   — React calls your component function.
              It returns a plain object tree (React elements), not DOM.
              Must be pure. Interruptible: React may pause, restart, or throw it away.

2. RECONCILE— Diff the new element tree against the current fiber tree.
              Produce a list of effects: insert / update / delete this node.

3. COMMIT   — Synchronous and uninterruptible:
              a. apply DOM mutations
              b. run useLayoutEffect cleanups, then useLayoutEffect bodies  ← blocks paint
              c. browser paints
              d. run useEffect cleanups, then useEffect bodies             ← after paint
Why render must be pure: React may call your component twice, or call it and discard the result. Side effects during render (mutating a module variable, calling an API, writing to a ref) can happen zero, one, or two times. StrictMode double-invokes in development specifically to expose this.
Practical consequence of (b) vs (d): if you measure a DOM node and immediately reposition it, use useLayoutEffect — it runs before paint, so the user never sees the intermediate position. useEffect would flash. Everything else should be useEffect, because layout effects block the frame.

22 State lives at a position, not in a component

React identifies a component by where it sits in the tree plus its type. Same position + same type means the same state, no matter what changed.

// State is PRESERVED — same type, same position, only props changed
{isEditing ? <Input value={draft} /> : <Input value={saved} />}

// State is DESTROYED — different type at that position, subtree unmounts
{isEditing ? <Input /> : <TextArea />}

// State is DESTROYED on purpose — key changes identity
<Profile key={userId} userId={userId} />

That last line is the idiomatic answer to "how do I reset state when a prop changes". You do not need an effect for it.

Never declare a component inside another component. Each parent render creates a brand-new function identity, which React treats as a different type — so the entire subtree unmounts and remounts on every render, wiping its state and refocusing inputs.
function Parent() {
  function Child() { return <input />; }   // new type every render — bug
  return <Child />;
}

23 Keys are identity, not order

Within a list of siblings, the key tells React "this is the same item as before". Using the array index tells React "the item in slot 2 is the same item as whatever used to be in slot 2" — which is false the moment you insert, delete, or sort.

{items.map((item, i) => <Row key={i}       item={item} />)}   // breaks on reorder
{items.map((item)    => <Row key={item.id} item={item} />)}   // correct

The symptom is distinctive: text content updates correctly, but anything React does not control — the value typed into an uncontrolled input, scroll position, CSS transition state, focus — sticks to the wrong row.

Index keys are fine for a list that is static, never reordered, and never filtered. If any of those three change, use a stable id.

React

State as a snapshot

Each render gets its own frozen copy of state, props, and every function and variable declared inside the component. Almost every "stale value" bug is this rule being violated by intuition.

24 setState schedules; it does not assign

function Counter() {
  const [count, setCount] = useState(0);   // 'count' is a const for THIS render

  function handleClick() {
    setCount(count + 1);   // count is 0 → queue "set to 1"
    setCount(count + 1);   // count is STILL 0 → queue "set to 1"
    console.log(count);    // 0 — nothing has changed yet
  }                        // result: 1

  function handleClickFixed() {
    setCount(c => c + 1);  // queue "add 1 to whatever is pending"
    setCount(c => c + 1);
  }                        // result: 2
}

React batches all state updates triggered in the same tick into a single re-render. Since React 18 this applies everywhere — event handlers, setTimeout, promise callbacks, native listeners — not only React event handlers.

Use the updater form setX(prev => ...) whenever the next value depends on the current one. Use the direct form when it does not. That is the whole rule.

25 Immutability is a comparison contract

React decides whether to re-render by running Object.is(prev, next). Mutating an object keeps its reference identical, so React concludes nothing changed.

// invisible to React — same reference
todos.push(newTodo);   setTodos(todos);
user.name = 'ada';     setUser(user);

// correct
setTodos([...todos, newTodo]);
setUser({ ...user, name: 'ada' });
setTodos(todos.map(t => t.id === id ? { ...t, done: true } : t));
For deeply nested state, reach for Immer (useImmer) rather than a five-level spread — or, better, flatten the state shape. Deeply nested React state is usually a modelling problem.

26 Do not store what you can compute

// antipattern: two sources of truth, one render behind
const [items, setItems]       = useState([]);
const [filtered, setFiltered] = useState([]);
useEffect(() => { setFiltered(items.filter(matches)); }, [items, query]);

// correct: derive during render
const [items, setItems] = useState([]);
const [query, setQuery] = useState('');
const filtered = items.filter(i => i.name.includes(query));   // just compute it
// wrap in useMemo only if profiling shows the filter is actually expensive
Instead of…Do this
An effect that computes state from props/stateCompute it during render
An effect that resets state when a prop changesPass a key
An effect that runs on a button clickPut it in the event handler
An effect that notifies a parent of a changeCall the parent's handler in the same event handler
State that only two siblings needLift to their closest common parent
State at the top of the app that one leaf usesColocate it in the leaf

React

Effects are synchronization, not lifecycle

useEffect is not componentDidMount. It answers a different question: "given the current props and state, what should the outside world look like — and how do I undo it?"

27 The setup / cleanup pair

useEffect(() => {
  const conn = createConnection(serverUrl, roomId);   // setup
  conn.connect();
  return () => conn.disconnect();                     // cleanup
}, [serverUrl, roomId]);

Read it as: "while this component is mounted with these values, a connection to that room should exist." When a dependency changes, React runs the cleanup for the old values, then setup for the new ones. Unmount runs cleanup once more.

StrictMode double-invokes effects in development — setup, cleanup, setup. This is not a bug to work around; it is a test that your effect is symmetric. If double-invoking breaks something (a duplicate analytics event, a doubled subscription), the cleanup is incomplete and it will break in production too, the first time React remounts a subtree.

The dependency array is not a schedule. It is a declaration of everything reactive the effect reads. You cannot choose it — the linter computes it. If the array feels wrong, the effect is wrong: move code inside, extract a stable function outside the component, or use useEffectEvent for the non-reactive part.

28 Every fetch in an effect is a race

Request for id=1 is slow, user clicks id=2, response 2 arrives, then response 1 lands and overwrites it. The UI now shows the wrong user with no error anywhere.

useEffect(() => {
  let stale = false;
  const ctrl = new AbortController();

  fetch(`/api/users/${id}`, { signal: ctrl.signal })
    .then(r => r.json())
    .then(data => { if (!stale) setUser(data); })
    .catch(err => { if (err.name !== 'AbortError' && !stale) setError(err); });

  return () => { stale = true; ctrl.abort(); };
}, [id]);
In real applications, do not write this. Use TanStack Query, SWR, or your framework's loader. They solve caching, deduplication, revalidation, retries, and this race in one place. Hand-rolled useEffect fetching is the most common source of subtle frontend bugs.

React

Referential identity and memoization

Every render creates new objects, arrays, and functions. Since React compares with Object.is, identity — not content — decides whether effects re-run and memoized children re-render.

29 Why your effect loops forever

function Search({ limit }) {
  const options = { limit };                       // new object every render

  useEffect(() => {
    load(options).then(setResults);                // setResults → re-render
  }, [options]);                                   // new identity → effect re-runs → loop
}

Fixes, in order of preference:

  1. Move the object inside the effect and depend on the primitive: useEffect(() => { load({ limit }) }, [limit])
  2. Move it outside the component entirely if it is constant.
  3. Only then: const options = useMemo(() => ({ limit }), [limit])
React.memo is defeated by a single unstable prop. <Row onSelect={() => pick(id)} /> creates a new function every render, so the shallow compare always fails and the memo does nothing but add overhead.

30 The React Compiler changes the calculus

The React Compiler (shipped alongside React 19) analyses your components at build time and inserts memoization automatically, based on which values actually flow where. On a compiled codebase, most hand-written useMemo / useCallback / React.memo becomes redundant.

Two things this does not change:

  • Your components must follow the Rules of React — pure render, no mutation of props or state, hooks called unconditionally at the top level. The compiler bails out (safely) on code it cannot prove is safe, and eslint-plugin-react-hooks tells you where.
  • It memoizes; it does not fix an O(n²) algorithm or an over-broad context. Architecture still matters.
Whether or not you adopt the compiler, the mental model to keep is: identity changes cause re-renders and effect re-runs. Memoization is a cache on top of that, and every cache costs memory and comparison time. Profile with the React DevTools Profiler before adding one by hand.

31 Context is injection, not a state manager

When a provider's value changes identity, every consumer re-renders, no matter which part of the value it reads.

// every consumer re-renders on every Parent render — the object is new each time
<AppCtx.Provider value={{ user, theme, setUser }}>

// better: stabilise
const value = useMemo(() => ({ user, theme, setUser }), [user, theme]);

// best: split by change frequency. dispatch from useReducer is referentially stable,
// so components that only dispatch never re-render when state changes.
<StateCtx.Provider value={state}>
  <DispatchCtx.Provider value={dispatch}>{children}</DispatchCtx.Provider>
</StateCtx.Provider>

The other lever is composition. An element passed as children is created by the parent, so it keeps its identity when the wrapper re-renders:

function Panel({ children }) {
  const [open, setOpen] = useState(false);
  return <div>{open && children}</div>;   // 'children' does not re-render when 'open' changes
}
React 19 lets you render the context object directly — <ThemeCtx value={theme}> instead of <ThemeCtx.Provider> — and use(ThemeCtx) can read context conditionally, which useContext cannot.

React

Concurrency, Suspense, and the server

The modern React features all follow from one capability: React can start rendering, pause, and either resume or abandon that work. That is what makes non-blocking updates and streaming HTML possible.

32 Transitions: marking updates as interruptible

const [query, setQuery]     = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();

function onChange(e) {
  setQuery(e.target.value);                    // urgent — the input must feel instant
  startTransition(() => {
    setResults(searchLargeList(e.target.value)); // non-urgent — can be interrupted
  });
}

If the user types another character mid-render, React throws away the in-progress result list and starts again with the newer value. Without a transition, the expensive render blocks the keystroke.

useDeferredValue is the same idea expressed as a value rather than a callback — useful when you do not own the setState call:

const deferredQuery = useDeferredValue(query);
const results = useMemo(() => searchLargeList(deferredQuery), [deferredQuery]);

33 Suspense: loading states as tree structure

<Suspense fallback={<PageSkeleton />}>
  <Profile />
  <Suspense fallback={<FeedSkeleton />}>
    <Feed />          {/* streams in later without holding up Profile */}
  </Suspense>
</Suspense>

A component "suspends" when it reads a resource that is not ready; React shows the nearest boundary's fallback and retries when it resolves. Combined with streaming SSR, the server sends the shell immediately and flushes each boundary's HTML as its data resolves.

Suspense catches pending, error boundaries catch failed. You need both — a Suspense boundary alone leaves a rejected promise to crash the tree. Note also that error boundaries are still class components (or a wrapper from react-error-boundary).

34 Server Components and the client boundary

Server Components run only on the server. They ship no JavaScript to the browser — only their rendered output — and can await data directly.

// app/users/page.tsx — a Server Component (the default in an RSC framework)
export default async function Page() {
  const users = await db.user.findMany();   // no useEffect, no loading state, no API route
  return <UserList users={users} />;
}
// UserList.tsx
'use client';                       // everything imported from here down is client code
import { useState } from 'react';

export function UserList({ users }) {
  const [q, setQ] = useState('');   // hooks require the client
  return /* ... */;
}
  • 'use client' marks a boundary, not a file-by-file toggle. Push it as far down the tree as you can.
  • Only serializable values cross the boundary: primitives, plain objects, arrays, Dates, Maps, Sets, promises, JSX — not arbitrary functions or class instances.
  • Server Actions ('use server') are the one exception: a function reference that the client can call, which executes on the server.
'use client';
import { useActionState } from 'react';

function NameForm({ saveName }) {                 // saveName is a Server Action
  const [state, formAction, pending] = useActionState(saveName, null);
  return (
    <form action={formAction}>
      <input name="name" />
      <button disabled={pending}>Save</button>
      {state?.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

Related React 19 additions worth knowing: useOptimistic for instant UI that reconciles when the action settles, useFormStatus for pending state in a nested submit button, use() for reading a promise or context, ref as a plain prop, and automatic hoisting of <title> / <meta> / stylesheets rendered anywhere in the tree.

Check yourself — React

Q10 Starting from 0, what does count equal after one click?

function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}
count is a constant captured by this render's closure — it stays 0 for all three calls, so all three queue "set to 1". Use the updater form setCount(c => c + 1) to get 3: those queue functions that each receive the pending value.

Q11 A list uses key={index}. Each row has an uncontrolled <input>. You type into row 3, then delete row 1. What happens?

After the deletion, what was row 3 becomes index 2. React sees "index 2 still exists" and reuses that DOM node, so React-controlled content (the item text) updates while DOM-owned state (input value, focus, scroll, transition state) stays attached to the old slot. Stable ids make the identity real.

Q12 In development your analytics event fires twice on mount. What is the right response?

StrictMode's double-invoke simulates a remount to prove your effect is restartable. A ref guard hides the symptom and the bug returns in production the first time React remounts the subtree (route changes, Suspense retries, offscreen rendering). For analytics specifically the real fix is usually that a page-view is an event, not a synchronization — fire it from the router, not an effect.

Q13 Row is wrapped in React.memo. Does it re-render when the parent re-renders?

<Row item={item} onSelect={() => select(item.id)} />
React.memo does a shallow Object.is compare of every prop. A new arrow function has a new identity every render, so the comparison always fails and you pay the comparison cost for nothing. Stabilise with useCallback, pass item.id and let Row call a stable handler, or let the React Compiler handle it.

Q14 You measure a tooltip's height and reposition it. Which hook, and why?

Commit order is: DOM mutations → layout effects → paint → passive effects. useEffect runs after the browser has already painted, so the user would see one frame at the wrong position. Use useLayoutEffect only for measure-then-mutate; it blocks the frame, so everything else belongs in useEffect.

Q15 Which prop can be passed from a Server Component to a Client Component?

The boundary is a serialization boundary. Primitives, plain objects/arrays, Dates, Maps, Sets, promises and JSX cross fine. Ordinary functions and class instances cannot — the client has no way to reconstruct them. Server Actions are the deliberate exception: what crosses is a reference the client can invoke over the network, not the function body.

Data & server state

Two kinds of state, and only one is yours

The most consequential architectural decision in a React app is recognising that "state" is two unrelated problems wearing the same word. Conflating them is what produces 4,000-line Redux stores full of isLoading flags.

35 Client state vs server state

Client stateServer state
OwnerYouSomeone else's database
AccessSynchronousAsynchronous
Goes stale on its ownNeverConstantly
Shared with other clientsNoYes
NeedsA place to put itCaching, dedup, retry, revalidation, invalidation
ExamplesModal open, selected tab, form draft, themeThe user, the todo list, search results
Right tooluseState, useReducer, Zustand, JotaiTanStack Query, SWR, RSC / a framework loader

Server state is not state you own — it is a cache of state you do not own. Once you name it that way, the requirements become obvious, and so does the fact that Redux never offered any of them.

The diagnostic smell: a global store containing data, isLoading, and error for each endpoint, populated by effects. You have hand-rolled a cache with no eviction, no deduplication, no revalidation, and no consistency guarantees — and you now own every bug in it. Cache invalidation is famously hard; you did not need to sign up for it.

36 The cache model: keys, staleness, invalidation

Every server-state library is the same four ideas. Learn them once and the specific library is an implementation detail.

const { data, isPending, error } = useQuery({
  queryKey: ['todos', { status, page }],   // the key IS the dependency array
  queryFn:  () => fetchTodos({ status, page }),
  staleTime: 30_000,       // how long the data is considered FRESH
  gcTime:    5 * 60_000,   // how long to keep it after the last observer unmounts
});
  • The key is cache identity. Structural, not a string — same key means the same entry. Change a value in the key and it is a different query, which is why the key doubles as your dependency array and why "refetch when the filter changes" needs no effect at all.
  • staleTime and gcTime are unrelated, and universally confused. staleTime answers "may I serve this without refetching?" gcTime answers "may I throw this away?" Default staleTime is 0 — data is stale the instant it arrives, so any trigger refetches. That is the setting people actually mean to change.
  • Triggers for a background refetch of stale data: a new component mounts with that key, the window regains focus, the network reconnects, or an interval fires. The cached value is shown immediately while the refetch happens underneath — stale-while-revalidate.
  • Deduplication: twelve components asking for ['user', id] in the same tick produce one request. This is what lets you drop prop drilling for server data entirely — just call the hook wherever you need it.
// invalidation after a write — prefix matching, so this clears
// ['todos'], ['todos', {page:1}], ['todos', {page:2}] ...
const qc = useQueryClient();
useMutation({
  mutationFn: createTodo,
  onSuccess: () => qc.invalidateQueries({ queryKey: ['todos'] }),
});
Design your keys as a hierarchy from the start — ['todos'], ['todos', 'list', filters], ['todos', 'detail', id] — and keep them in one factory module. Ad-hoc keys scattered across components make invalidation guesswork, and it is a painful refactor later.

37 Optimistic updates and rollback

Four steps, and the first one is the one everybody forgets.

useMutation({
  mutationFn: toggleTodo,
  onMutate: async (next) => {
    // 1. CANCEL — an in-flight refetch would land later and clobber our write
    await qc.cancelQueries({ queryKey: ['todos'] });
    // 2. SNAPSHOT — the rollback value
    const prev = qc.getQueryData(['todos']);
    // 3. WRITE optimistically
    qc.setQueryData(['todos'], (old) => applyToggle(old, next));
    return { prev };
  },
  // 4a. ROLL BACK on failure
  onError:   (_err, _vars, ctx) => qc.setQueryData(['todos'], ctx.prev),
  // 4b. RECONCILE either way — the server is still the source of truth
  onSettled: () => qc.invalidateQueries({ queryKey: ['todos'] }),
});
Skipping cancelQueries produces the worst kind of bug: the UI flickers back to the old value for a moment, or intermittently reverts entirely, depending on network timing. It reproduces on a colleague's slow connection and never on yours.

React 19's useOptimistic covers the simpler local case — show a pending value while an action runs, and drop it automatically when the action settles and the real state arrives:

const [optimisticTodos, addOptimistic] = useOptimistic(
  todos,
  (state, newTodo) => [...state, { ...newTodo, pending: true }]
);

38 Waterfalls, and where the boundary moved

A request waterfall is when request B cannot start until request A finishes, purely because of how the code is arranged. It is the single largest source of slow pages, and it is invisible in code review — you only see it in the network panel.

<Page>              fetch user      ————>
  <Profile>           (waits) fetch posts    ————>
    <Comments>          (waits) fetch comments      ————>
                                                          3x latency

Each nested component fetching its own data creates a sequential chain. The fixes, in rough order of preference:

  • Hoist the fetch to a route loader or Server Component that requests everything in parallel (Promise.all), then passes data down.
  • Prefetch on intent — start the request on link hover or focus, before the click.
  • Render-as-you-fetch — kick off the request and pass the promise down to a Suspense boundary, rather than fetching inside the child on mount.
  • Parallel Suspense boundaries — siblings suspend independently and stream in as each resolves.

With RSC and framework loaders, much of this moves off the client entirely: data is fetched next to the database, and the client never holds a cache for it. The senior-level decision is the split — for each piece of data, is it server-rendered (static-ish, SEO-relevant, no interactivity), client-cached (user-specific, refetched, mutated), or real-time (WebSocket/SSE, pushed)? Those three have different failure modes and different loading UX, and mixing them arbitrarily is how apps end up with six competing sources of truth.

Check yourself — server state

Q16 What is the difference between staleTime and gcTime?

They govern different axes. staleTime is about freshness — within it, a mount or window focus serves the cached value with no network call. gcTime is about memory — it starts counting only once nothing is observing the key. The default staleTime: 0 means every mount triggers a background refetch, which surprises people who assumed the cache would prevent it.

Q17 In an optimistic update, why must you await cancelQueries() before writing to the cache?

A background refetch that was already in the air carries pre-mutation data. If it lands after your optimistic write, it silently reverts the UI. The bug is timing-dependent, so it reproduces on slow connections and almost never locally — which is exactly the kind of defect that reaches production.

Q18 A codebase keeps the user list in a Zustand store, populated by useEffect, with isLoading and error alongside. What is the core problem?

Client-state libraries store values; they make no claims about freshness. Putting server data in one means you personally own deduplication, retry, background revalidation, focus refetching, eviction, and cross-component consistency. A server-state library provides all of that as defaults. Keep the store for state you genuinely own — the open modal, the draft, the theme.

Q19 Three nested components each fetch on mount: UserPostsComments. Page load takes 3× the latency. What is this, and what is the best fix?

Each request is blocked on its parent rendering, which is blocked on the parent's request. Nothing is wrong with any individual component — the latency is emergent from the composition, which is why it survives code review. Hoist to a loader or Server Component and use Promise.all, or prefetch on intent. Memoization and cancellation do not touch this.

Component design

Designing a prop surface you won't regret

The difference between someone who uses a design system and someone who can build one. These decisions are cheap to make on day one and expensive to change once forty call sites depend on them.

39 Controlled, uncontrolled, and supporting both

Who owns the state? Uncontrolled means the component does, and the parent is merely notified. Controlled means the parent does, and the component is a pure function of props. Good primitives support both.

<Select defaultValue="a" onChange={log} />   // uncontrolled: easy, no wiring
<Select value={v} onChange={setV} />         // controlled: parent can force a value
function useControllableState<T>(opts: {
  value?: T; defaultValue: T; onChange?: (v: T) => void;
}) {
  const { value, defaultValue, onChange } = opts;
  const [internal, setInternal] = useState(defaultValue);
  const isControlled = value !== undefined;          // decided ONCE, by convention
  const state = isControlled ? value : internal;

  const setState = useCallback((next: T) => {
    if (!isControlled) setInternal(next);            // controlled: parent must re-render
    onChange?.(next);
  }, [isControlled, onChange]);

  return [state, setState] as const;
}
The mode must not change at runtime. If value starts undefined and later becomes defined, the component flips from uncontrolled to controlled and state jumps. React warns about this on native inputs for a reason. The usual cause is value={data?.name} before the data loads — pass value={data?.name ?? ''}, or don't render until it resolves.

40 Composition beats configuration

The smell is prop growth. Every new design requirement adds a prop, each prop multiplies the states you must support, and eventually nobody can tell which combinations are legal.

// configuration — 9 props today, 14 next quarter
<Modal
  title="Delete project?"  headerIcon={<Warn />}  showCloseButton
  size="sm"  variant="danger"  footer={<Buttons />}  footerAlign="right"
  bodyClassName="pt-2"  onCloseClick={close}
/>

// composition — you own behaviour, the consumer owns arrangement
<Modal open={open} onOpenChange={setOpen}>
  <Modal.Header><Warn /> Delete project?</Modal.Header>
  <Modal.Body>This cannot be undone.</Modal.Body>
  <Modal.Footer>
    <Button onClick={close}>Cancel</Button>
    <Button variant="danger" onClick={del}>Delete</Button>
  </Modal.Footer>
</Modal>

The subparts share state through a context that Modal provides, so there is no prop drilling and no configuration matrix. This is how Radix, Headless UI, and Ark are built.

The trade-off is real, though. Composition maximises flexibility; configuration maximises consistency. A design system usually wants both layers: composable primitives underneath, and a small set of opinionated, configured components on top that cover the 90% case. Ship only the primitives and every team reinvents the same modal slightly differently — which is the problem the design system existed to solve.

41 Separate behaviour from appearance

Keyboard interaction, ARIA wiring, focus management, and collision-aware positioning are genuinely hard and almost never the part your team wants to own. A combobox alone needs type-ahead, virtual focus, aria-activedescendant, and about a dozen keyboard cases. Rewriting that per design system is how organisations ship inaccessible widgets at scale.

  • Headless libraries (Radix, React Aria, Ark, Headless UI) provide behaviour with no styles. You provide the pixels. This is close to a default for new design systems.
  • asChild / polymorphic as lets a consumer swap the rendered element — <Button asChild><Link href="/x">Go</Link></Button> — so a button's styling and behaviour can wrap a router link without a wrapper div. Be aware it is genuinely expensive to type well in TypeScript.
  • Render props still earn their place when the consumer needs the component's internal state: <Disclosure>{({ open }) => ...}</Disclosure>.
  • Always accept className, style, and ref, and spread ...rest onto the root element. A component that cannot be positioned or measured by its parent is unusable in someone else's layout, and this is the most common reason people fork a component instead of importing it.

42 Smells, and what counts as a breaking change

SmellUsually means
isPrimary, isSecondary, isDangerAn enum wearing three booleans. Use variant, which makes the illegal combinations unrepresentable.
Props only valid togetherA discriminated union of prop shapes, not a flat optional bag.
containerClassName, innerStyle, wrapperPropsMissing composition. The consumer wants to control a subpart — expose it as one.
onChange(event) on a custom componentCallers want the value. Synthetic events are a DOM concern; do not leak them upward.
A prop that is read only on mountName it defaultX so the "changes are ignored" contract is visible.
Six optional props, no defaults documentedDefaults belong in the signature, where the types show them.
The one people get wrong: changing a default value is a breaking change, even though no type changes and nothing fails to compile. So is tightening a prop's type, making an optional prop required, changing the rendered DOM element, and changing which element ...rest lands on. Adding an optional prop is the only reliably safe move. Treat a component's rendered structure as part of its public API — because somebody's CSS selector already depends on it.

Check yourself — component design

Q20 An input renders value={user?.name} while user is still loading. What happens when the data arrives?

value === undefined means uncontrolled, so React lets the DOM own the value. When user resolves, value becomes a string and React takes ownership mid-flight. The fix is to make the mode constant: value={user?.name ?? ''}, or don't render the form until the data is there. The same rule applies to your own components if you support both modes — decide once, at mount.

Q21 Your <Card> has grown to title, subtitle, icon, badge, footer, footerAlign, headerClassName, and bodyPadding. What is the underlying problem?

Props like footerAlign and headerClassName are the tell: the consumer is reaching past your API to control a region you own. Exposing Card.Header / Card.Body / Card.Footer hands them arrangement while you keep behaviour and shared state via context. It also stops the combinatorial explosion of prop states you would otherwise have to test.

Q22 You change Button's default size from "md" to "sm". Everything still compiles. What version bump?

Semver is about observable behaviour, not compilability. Every consumer who relied on the default gets a different rendering with no warning and no diff on their side — the worst kind of upgrade. The same applies to changing the rendered element, moving where ...rest is spread, or tightening a prop type. Type-checking is not a substitute for a compatibility policy.

Package management

Semver and ranges

Your package.json does not specify versions. It specifies ranges — a contract about which future versions you are willing to accept without review. Reading them precisely is the difference between a reproducible build and a Tuesday morning outage.

43 What each range actually means

MAJOR . MINOR . PATCH  -  PRERELEASE  +  BUILD
  2   .   7   .   1    -   beta.3     +  20260714
  |       |       |
  |       |       +-- backwards-compatible bug fixes
  |       +---------- backwards-compatible new features
  +------------------ breaking changes
RangeResolves toNote
^1.2.3>=1.2.3 <2.0.0npm's default. Allows minor + patch.
^0.2.3>=0.2.3 <0.3.0Special case. Below 1.0.0, minor is treated as breaking.
^0.0.3>=0.0.3 <0.0.4Effectively pinned.
~1.2.3>=1.2.3 <1.3.0Patch only.
~1.2>=1.2.0 <1.3.0
1.2.x / 1.2>=1.2.0 <1.3.0
1.2.3exactly 1.2.3Pinned. Still not reproducible on its own — transitive deps are unpinned.
* / latestanythingDo not.
Prereleases never match a plain range. ^1.2.3 will not install 2.0.0-beta.1, and will not even install 1.3.0-rc.1. A range only accepts a prerelease when it explicitly names one at the same major.minor.patch tuple — ^1.3.0-rc.1 accepts 1.3.0-rc.2.
Semver is a promise, not a mechanism. Nothing stops a maintainer from shipping a breaking change in a patch. That is precisely why the lockfile exists.

44 The five dependency fields

FieldInstalled for consumers?Use for
dependenciesYesAnything imported by shipped code
devDependenciesNoBuild, test, lint, types
peerDependenciesAuto-installed by npm 7+ if missing"The host app must provide this, and we must share one copy"
optionalDependenciesYes, failure ignoredPlatform-specific native binaries
bundledDependenciesShipped inside the tarballRare; vendoring

Peer dependencies are the one that bites. If you publish a React component library and put react in dependencies, every consumer gets a second copy of React nested under your package. Two Reacts means two independent hook dispatchers, and the app dies with "Invalid hook call" or context that silently reads as undefined.

{
  "peerDependencies":     { "react": "^18.0.0 || ^19.0.0" },
  "peerDependenciesMeta": { "react-dom": { "optional": true } },
  "devDependencies":      { "react": "^19.2.0" }
}
The rule: peer for anything that must be a singleton (React, react-dom, a styling runtime, a state library your consumers also use) plus dev so you can build and test locally. Keep the peer range as wide as you can actually support.

Package management

How node_modules gets its shape

Node's resolution algorithm is dumb on purpose: from the importing file, look in ./node_modules; if not found, go up one directory and repeat until the filesystem root. Every package manager's design is a different answer to "what tree do we build so that algorithm finds the right thing?"

45 Hoisted vs isolated layouts

npm / yarn classic  (flat, hoisted)      pnpm  (isolated, symlinked)

node_modules/                            node_modules/
├── react/            <- hoisted         ├── react      -> .pnpm/react@19.2.0/.../react
├── lodash/           <- hoisted         ├── ui-kit     -> .pnpm/ui-kit@2.1.0/.../ui-kit
└── ui-kit/                              └── .pnpm/
    └── node_modules/                        ├── react@19.2.0/node_modules/react
        └── lodash/   <- version conflict     ├── lodash@4.17.21/node_modules/lodash
                         forced it nested     └── lodash@3.10.1/node_modules/lodash
                                                  (hardlinks into a global store)

Two failure modes fall out of the hoisted layout:

  • Phantom dependencies. lodash is hoisted to the top because a dependency needed it, so import _ from 'lodash' works in your code even though you never declared it. It works until that dependency drops lodash, and then your build breaks with no change on your side.
  • Doppelgangers. When two packages need incompatible versions, one gets nested — so two copies exist. Harmless for a formatting utility, fatal for React, and quietly doubles your bundle.

pnpm's layout makes only declared dependencies reachable (no phantoms), stores each version once globally and hardlinks it (fast installs, small disk), and keeps the nesting explicit. Yarn's Plug'n'Play goes further and removes node_modules entirely in favour of a resolution map.

Debugging commands worth memorizing: npm ls react shows every copy in the tree and who asked for it; npm why <pkg> / pnpm why <pkg> explains why something is installed at all. When you hit "Invalid hook call" or a mysteriously large bundle, start here.

46 The lockfile is the build

package.json is intent. The lockfile is the resolved, exact, integrity-checked tree — every transitive dependency, pinned, with a sha512 hash of each tarball.

npm installnpm ci
Readspackage.json, then the lockfileThe lockfile only
May change the lockfileYesNever
Existing node_modulesReconciled in placeDeleted first
If they disagreeResolves and rewritesFails the build
Use inLocal developmentCI, Docker, anything reproducible
  • Always commit the lockfile — for applications and libraries. It does not affect your consumers (they resolve their own tree), but it makes your own CI deterministic.
  • Commit exactly one. A repo with both package-lock.json and pnpm-lock.yaml will drift, and the two managers will build different trees.
  • A lockfile conflict in git is resolved by taking either side and re-running the install, not by hand-merging.

Package management

The lifecycle scripts

There are three separate lifecycles, and most confusion comes from conflating them: install, run, and publish/pack. Each fires a fixed sequence of named scripts from the scripts field.

47 Install lifecycle

npm install                (in your own project, no package argument)

  preinstall
    ↓   [ resolve the graph → download tarballs → verify integrity → extract ]
  install
    ↓
  postinstall
    ↓
  prepare              ← runs on a bare 'npm install', but NOT with --production
                          and NOT when your package is installed as a dependency

prepare is the hook people reach for and get wrong. It fires in three situations:

  • A bare local npm install — this is why "prepare": "husky" is the standard way to install git hooks.
  • Before npm pack and npm publish — so it is also the right place to build a library from source.
  • When your package is installed from a git URL, so a git dependency can be built on the consumer's machine.
Every dependency's install scripts run on your machine. A transitive package with a malicious postinstall executes with your user's permissions the moment you install. This is the primary npm supply-chain attack vector. Mitigations: npm ci --ignore-scripts in CI where nothing needs to compile, pnpm's onlyBuiltDependencies allowlist, and pinning via the lockfile so a compromised new release is not picked up automatically.

48 Run lifecycle: pre and post for any script

{
  "scripts": {
    "prebuild":  "rimraf dist",
    "build":     "tsc -b && vite build",
    "postbuild": "node scripts/report-size.mjs",

    "pretest":   "npm run lint",
    "test":      "vitest run"
  }
}

$ npm run build     →  prebuild, build, postbuild
$ npm test          →  pretest, test, posttest
  • The pre/post convention works for any script name, not just the built-in ones.
  • Since npm 7, pre/post only fire via npm run <name> (and the built-in aliases npm test, npm start, npm publish…). Executing the binary directly skips them.
  • node_modules/.bin is prepended to PATH inside a script, which is why "build": "vite build" works without a path.
  • Everything after -- is forwarded: npm run test -- --watch.
  • Chaining with && is sequential and not cross-platform-safe for env vars; use npm-run-all, concurrently, or a task runner (Turborepo, Nx) for parallelism and caching.

49 Publish and version lifecycles

npm publish
  prepublishOnly     ← publish only. The right place for tests + typecheck gates.
  prepack            ← also runs for 'npm pack' and git installs
  prepare            ← the build step
  postpack
  [ upload tarball to the registry ]
  publish
  postpublish

npm version minor
  preversion         ← e.g. run the test suite before allowing a bump
  version            ← bump written; commit the changes you make here
  postversion        ← e.g. git push && git push --tags

What actually goes in the tarball is controlled by the files allowlist (preferred) or .npmignore. Inspect it before you ship:

$ npm pack --dry-run          # lists every file and the unpacked size
$ npm publish --dry-run
$ npm publish --access public # required the first time for a scoped package
$ npm publish --tag next      # publish without moving the 'latest' dist-tag
$ npm dist-tag add pkg@2.1.0 latest
dist-tags are mutable pointers to versions. latest is what a bare npm i pkg installs. Publishing a beta under --tag next means early adopters run npm i pkg@next while everyone else stays on stable. Forgetting --tag on a prerelease is a classic way to break every consumer at once.

Package management

Module formats and the exports field

The CommonJS-to-ESM transition is the source of a large share of "it works in dev but not in the build" errors. The exports field is how a modern package declares, precisely, what each consumer environment should load.

50 CJS vs ESM

CommonJSES Modules
Syntaxrequire() / module.exportsimport / export
ResolutionRuntime, dynamic, synchronousStatic, hoisted, async graph
BindingsCopied valuesLive bindings
Tree-shakeableNoYes
Top-level awaitNoYes

Which one a .js file is depends on the nearest package.json: "type": "module" makes it ESM, absence or "type": "commonjs" makes it CJS. .mjs and .cjs are always explicit.

Dual package hazard: a package published in both formats can be loaded twice in one process — once via require, once via import. You then have two copies of its module-level state: two singletons, two class identities, so instanceof fails and a registry appears empty. Avoid by keeping stateful packages ESM-only, or by having the CJS build be a thin wrapper over one shared core.

51 A well-formed modern package.json

{
  "name": "@acme/ui",
  "version": "2.1.0",
  "type": "module",
  "sideEffects": ["*.css"],
  "files": ["dist"],
  "exports": {
    ".": {
      "types":   "./dist/index.d.ts",
      "import":  "./dist/index.js",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    },
    "./button": {
      "types":  "./dist/button.d.ts",
      "import": "./dist/button.js"
    },
    "./styles.css": "./dist/styles.css",
    "./package.json": "./package.json"
  },
  "main":  "./dist/index.cjs",
  "types": "./dist/index.d.ts"
}
  • Conditions are matched in order, first match wins. "types" must be listed first or TypeScript may resolve to the wrong declaration. "default" must be last.
  • exports is an encapsulation boundary: once present, consumers can no longer deep-import @acme/ui/dist/internal/thing.js. That is a feature — it lets you refactor internals without a major bump — but it is also why upgrading a dependency suddenly breaks a deep import that used to work.
  • Export ./package.json explicitly; a surprising number of tools read it.
  • "sideEffects": false (or an array of the files that do have side effects, like CSS) is what unlocks aggressive tree-shaking. Get it wrong and your stylesheet vanishes from the bundle.
  • main / module / browser are the legacy fallbacks for tools that predate exports. Keep main, drop the rest when you can.

52 Workspaces, overrides, and hygiene

// root package.json
{
  "private": true,
  "workspaces": ["apps/*", "packages/*"],
  "overrides": { "semver": "^7.5.4" }
}

// apps/web/package.json  — depend on a sibling by workspace protocol
{ "dependencies": { "@acme/ui": "workspace:*" } }
  • One lockfile at the root, one install, siblings symlinked — so an edit in packages/ui is live in apps/web with no publish step.
  • overrides (npm / pnpm) and resolutions (yarn) force a version on a transitive dependency. The standard escape hatch when a nested package has a CVE and its parent has not updated. Treat every entry as tech debt with an owner.
  • Turborepo / Nx add a task graph with content-addressed caching on top: turbo run build skips packages whose inputs are unchanged.
  • Hygiene worth automating: npm audit in CI, Renovate or Dependabot for scheduled bumps (small and frequent beats a yearly big-bang), npm publish --provenance from CI to attest where the tarball was built, and a policy on install scripts.

Check yourself — packages & lifecycle

Q23 Which versions satisfy ^0.2.3?

Caret allows changes that do not modify the left-most non-zero component. For 0.x.y that component is the minor, so ^0.2.3 behaves like ~0.2.3. This exists because pre-1.0 packages routinely break on minor bumps — and it is why so many packages sit at 0.x forever on purpose.

Q24 Your CI runs npm install. A teammate added a dependency to package.json but forgot to commit the updated lockfile. What happens?

npm install treats the lockfile as a starting point and rewrites it to satisfy package.json. CI passes with a dependency tree that exists on no developer's machine — the classic "works in CI, breaks in prod". npm ci fails loudly on that mismatch, which is exactly what you want in an automated environment.

Q25 You want git hooks installed whenever a teammate clones the repo and runs npm install. Which script?

prepare runs on a bare local npm install but is skipped when your package is installed as someone else's dependency and under --production. That is exactly the semantics you want for developer tooling — hence "prepare": "husky". postinstall would also fire for consumers of your package, which is both rude and a security smell.

Q26 Your component library lists react in dependencies. What is the most likely consequence for consumers?

If the consumer's React version does not satisfy your range, the resolver nests a second copy. Two Reacts means two module-level hook dispatchers and two separate context registries — hooks throw, and useContext returns the default value for providers rendered by the "other" React. Singletons belong in peerDependencies (plus devDependencies so you can build).

Q27 After upgrading a dependency, import x from 'lib/dist/internal/util.js' stops resolving. Why?

exports is an encapsulation boundary: once declared, only the listed subpaths are reachable, and everything else is a hard resolution error. Deep-importing internals was always relying on an implementation detail. The fix is to use a public entry point, or open an issue asking the maintainer to export the subpath.

Q28 Your code does import _ from 'lodash' and it works, but lodash is not in your package.json. What is this called, and why does it matter?

A flat node_modules hoists transitive dependencies to the top level, where Node's resolution algorithm finds them. Your build breaks the day the real dependent drops it or bumps to an incompatible major — with no change in your own diff. pnpm's isolated layout makes only declared dependencies resolvable, which turns this into an error at install time instead of a mystery six months later.

Going deeper

Building the vertical bar

The concepts above are the load-bearing ones. Depth comes from the layers on either side of them — and from the parts of frontend that are not framework knowledge at all.

1

Forms — the hardest problem nobody teaches

Validation timing (on blur, on change after first blur, on submit), async and cross-field validation, dirty vs touched vs submitted, field arrays and nested paths, mapping server-side errors back onto fields, unsaved-changes guards, and multi-step flows. React Hook Form or TanStack Form plus a schema (Zod, Valibot) as the single source of truth for both types and validation. Almost every app gets this half-right and pays for it forever.

2

The network and security layer

CORS (and why the preflight is failing), cookie attributes — SameSite, Secure, HttpOnly — and why a JWT in localStorage is an XSS jackpot, Cache-Control vs ETag, CSP and what dangerouslySetInnerHTML actually costs you, and how your auth session really flows. This is where your backend depth transfers directly, and where most frontend engineers are weakest.

3

Measure before you optimize

Core Web Vitals (LCP, INP, CLS), the Performance panel's flame chart, the React DevTools Profiler, and bundle analysis (rollup-plugin-visualizer, source-map-explorer). Learn what a long task looks like and which of the three machines caused it. Performance intuition is almost always wrong; the profiler is almost always right.

4

Validate at the boundary

Types are erased at runtime, so every network response, URL param, localStorage read, and env var is unknown until proven otherwise. Zod or Valibot at the edge, with types inferred from the schema rather than declared twice. This single habit removes a whole category of production error.

5

Test at the right level

Testing Library (query by role and accessible name, never by class), MSW for network mocking, Playwright for the handful of real user journeys that must not break. The heuristic that holds up: test behaviour a user could describe, not implementation a refactor would change.

6

Own the delivery path

Rendering strategy (CSR / SSR / SSG / ISR / RSC) and its cache implications, CDN and cache-control headers, content hashing and long-term caching, code splitting per route, preload/prefetch. Most frontend performance is decided at the edge, not in a component — and this is the part of frontend that looks most like the backend work you already know.

7

Read one real codebase

Pick a library you use — TanStack Query, Zustand, Radix UI — and read it end to end. Their source is where the concepts on this page stop being abstract: you will find the identity comparisons, the discriminated unions, the controllable-state hooks, the peer dependency declarations, and the exports map all doing real work.

Primary sources, in priority order: react.dev (especially "Escape Hatches" and "You Might Not Need an Effect") · the TypeScript Handbook's "Type Manipulation" chapter · the npm docs page on scripts · Node's "Modules: Packages" page for exports resolution · MDN for anything platform-level.