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.
FoundationsCSS & the platformTypeScriptReactServer stateComponent designPackages & lifecycle
No concepts match that filter.
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:
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.
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.
Stage
What happens
Typical tool
Type check
Verifies types, emits nothing
tsc --noEmit
Transpile
Erases types, downlevels syntax, compiles JSX
esbuild, SWC, Babel
Resolve & bundle
Walks the import graph, inlines modules
Rollup, Rspack, webpack
Tree-shake
Drops statically-unreachable ESM exports
Rollup / bundler
Split & minify
Chunks per route, renames identifiers
Bundler + 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:
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:
Don't use ARIA if a native element does the job. A <button> beats role="button" every time.
Don't override native semantics (<h2 role="tab"> throws away the heading).
Every interactive ARIA control must be fully keyboard operable — ARIA changes announcements, never behaviour.
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.
Every interactive element needs an accessible name. Icon-only buttons need aria-label.
Where accessible names come from, in order: aria-labelledby → aria-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.
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.
12type vs interface
interface
type
Object shapes
Yes
Yes
Unions, tuples, primitives
No
Yes
Mapped / conditional types
No
Yes
Declaration merging
Yes (reopens)
No (duplicate error)
Implicit index signature
No
Yes
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:
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: trueand 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
}
15unknown, 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:
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
Type
Does
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)[].
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.
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.
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/state
Compute it during render
An effect that resets state when a prop changes
Pass a key
An effect that runs on a button click
Put it in the event handler
An effect that notifies a parent of a change
Call the parent's handler in the same event handler
State that only two siblings need
Lift to their closest common parent
State at the top of the app that one leaf uses
Colocate 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?"
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:
Move the object inside the effect and depend on the primitive: useEffect(() => { load({ limit }) }, [limit])
Move it outside the component entirely if it is constant.
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:
<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?
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.
Q13Row is wrapped in React.memo. Does it re-render when the parent re-renders?
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 state
Server state
Owner
You
Someone else's database
Access
Synchronous
Asynchronous
Goes stale on its own
Never
Constantly
Shared with other clients
No
Yes
Needs
A place to put it
Caching, dedup, retry, revalidation, invalidation
Examples
Modal open, selected tab, form draft, theme
The user, the todo list, search results
Right tool
useState, useReducer, Zustand, Jotai
TanStack 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:
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.
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: User → Posts → Comments. 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.
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
Smell
Usually means
isPrimary, isSecondary, isDanger
An enum wearing three booleans. Use variant, which makes the illegal combinations unrepresentable.
Props only valid together
A discriminated union of prop shapes, not a flat optional bag.
containerClassName, innerStyle, wrapperProps
Missing composition. The consumer wants to control a subpart — expose it as one.
onChange(event) on a custom component
Callers want the value. Synthetic events are a DOM concern; do not leak them upward.
A prop that is read only on mount
Name it defaultX so the "changes are ignored" contract is visible.
Six optional props, no defaults documented
Defaults 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
Range
Resolves to
Note
^1.2.3
>=1.2.3 <2.0.0
npm's default. Allows minor + patch.
^0.2.3
>=0.2.3 <0.3.0
Special case. Below 1.0.0, minor is treated as breaking.
^0.0.3
>=0.0.3 <0.0.4
Effectively pinned.
~1.2.3
>=1.2.3 <1.3.0
Patch only.
~1.2
>=1.2.0 <1.3.0
1.2.x / 1.2
>=1.2.0 <1.3.0
1.2.3
exactly 1.2.3
Pinned. Still not reproducible on its own — transitive deps are unpinned.
* / latest
anything
Do 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
Field
Installed for consumers?
Use for
dependencies
Yes
Anything imported by shipped code
devDependencies
No
Build, test, lint, types
peerDependencies
Auto-installed by npm 7+ if missing
"The host app must provide this, and we must share one copy"
optionalDependencies
Yes, failure ignored
Platform-specific native binaries
bundledDependencies
Shipped inside the tarball
Rare; 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.
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?"
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 install
npm ci
Reads
package.json, then the lockfile
The lockfile only
May change the lockfile
Yes
Never
Existing node_modules
Reconciled in place
Deleted first
If they disagree
Resolves and rewrites
Fails the build
Use in
Local development
CI, 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
CommonJS
ES Modules
Syntax
require() / module.exports
import / export
Resolution
Runtime, dynamic, synchronous
Static, hoisted, async graph
Bindings
Copied values
Live bindings
Tree-shakeable
No
Yes
Top-level await
No
Yes
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.
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.
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.