Senior React / Frontend
A complete set of senior-level React and Frontend interview questions covering React internals, Fiber, hooks deep-dives, state management, rendering patterns, Next.js App Router, TypeScript, performance, testing, CSS architecture, accessibility, and browser fundamentals.
React Internals
10 questionsThe original stack reconciler (React ≤15) processed the component tree synchronously and recursively, like a call stack. Once reconciliation started it could not be interrupted — a large update would block the main thread, causing dropped frames.
Fiber (React 16+) reimplements the reconciler as a linked list of work units called fibers, one per component instance. Because the reconciliation is now a data structure rather than a call stack, React can pause, resume, prioritise, and abort work mid-tree.
React walks the fiber tree in two phases:
- Render phase (reconciliation) — pure and interruptible. Diffs the old fiber tree against the new one, building a work-in-progress tree and marking effect flags (placement, update, deletion).
- Commit phase — synchronous and uninterruptible. Applies DOM mutations, runs
useLayoutEffect, then runsuseEffectasynchronously.
Fiber is the foundation for all Concurrent Mode features: Suspense, transitions (useTransition), and the React Compiler optimisations in React 19.
// Each fiber node stores:
{
type, // component function or DOM tag
key, // reconciliation key
pendingProps, // props for this render
memoizedProps, // props from last completed render
memoizedState, // hook linked list
flags, // effect bitmask (Update, Placement, Deletion…)
return, // parent fiber
child, // first child fiber
sibling, // next sibling fiber
alternate // the other tree (double-buffering)
}
The virtual DOM is a lightweight JavaScript representation of the DOM. On each render React creates a new virtual tree, diffs it against the previous one (reconciliation), and applies only the minimal set of real DOM mutations needed.
It is NOT always faster. Creating and diffing virtual trees adds CPU and memory overhead. For simple, infrequent updates, direct element.textContent = x is faster. The virtual DOM wins when you have complex UIs with many components and frequent state changes from multiple sources — it batches and minimises real DOM mutations, which trigger expensive layout and paint.
Alternatives that skip the virtual DOM:
- Svelte — compiles components to imperative DOM instructions at build time. Zero runtime diffing.
- Solid.js — fine-grained reactivity with signals. Updates only the exact DOM node that depends on changed state.
- React Compiler (React 19) — automatically memoises components, reducing re-renders and generating more surgical DOM updates — closing the gap with signal-based frameworks.
A full tree diff is O(n³). React reduces this to O(n) with two heuristics:
- Same type assumption — if two elements at the same position in the tree have different types, React tears down the old subtree and builds a new one from scratch.
- Keys for lists — React uses the
keyprop to match old and new elements in a list. Without keys React matches by index, so prepending an item appears to change every item below it.
Limitations:
- Changing a component's type high in the tree causes a full subtree unmount/remount — all state is lost — even if the children are identical.
- Index-as-key on a reordered or prepended list causes components to reuse stale state from the wrong item.
- Random keys (
Math.random()) unmount and remount every item on every render.
// Wrong — index breaks on reorder or prepend
items.map((item, i) => <Item key={i} data={item} />)
// Correct — stable, unique identity
items.map(item => <Item key={item.id} data={item} />)
React Server Components (RSC) run exclusively on the server — their component code never ships to the browser. They can directly access databases, file systems, and server-side APIs without an API route.
Key characteristics:
- No interactivity — cannot use
useState,useEffect, or event handlers. - Zero bundle cost — heavy libraries used only in an RSC add no bytes to the client bundle.
- Async by default — can be
asyncfunctions, usingawaitdirectly to fetch data. - RSC payload — the server streams a compact JSON-like format describing the render; the client React runtime merges it without discarding existing client state.
vs traditional SSR: Traditional SSR renders full HTML then ships the full component JS to the browser for hydration. RSC renders only server parts on the server — client components are still hydrated normally. A Server Component can render a Client Component as a child, but not vice versa (a Client Component cannot import a Server Component).
// app/products/page.tsx — Server Component (default in Next.js App Router)
async function ProductsPage() {
const products = await db.query('SELECT * FROM products');
return (
<main>
<ProductList products={products} /> {/* Server Component */}
<AddToCartButton /> {/* Client Component — 'use client' */}
</main>
);
}
Batching groups multiple setState calls into a single re-render. Before React 18, batching only happened inside React event handlers. Updates inside setTimeout, Promise.then, or native event listeners caused a separate re-render per setState call.
// React 17 — 2 re-renders inside setTimeout
setTimeout(() => {
setCount(c => c + 1); // re-render 1
setFlag(f => !f); // re-render 2
}, 1000);
// React 18 — 1 re-render (automatic batching everywhere)
setTimeout(() => {
setCount(c => c + 1); // } batched →
setFlag(f => !f); // } single re-render
}, 1000);
React 18 automatic batching extends batching to all sources — setTimeout, Promise callbacks, native event listeners, and fetch callbacks. Enabled automatically when using createRoot().
To opt out of batching (rare), use ReactDOM.flushSync():
import { flushSync } from 'react-dom';
flushSync(() => setCount(c => c + 1)); // forces immediate re-render
flushSync(() => setFlag(f => !f)); // second re-render
useTransition and useDeferredValue work?Concurrent Mode enables React to work on multiple versions of the UI simultaneously — preparing a new render in the background while keeping the current UI interactive. Enabled by default with createRoot (React 18+).
useTransition — marks a state update as low-priority. React renders urgent updates first (the UI stays interactive) and works on the transition in the background. If a higher-priority update arrives, the transition is interrupted and restarted.
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
setQuery(e.target.value); // urgent — input stays responsive
startTransition(() => {
setResults(expensiveSearch(e.target.value)); // deferred — non-urgent
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<ResultsList results={results} />
</>
);
}
useDeferredValue — defers re-rendering of a derived value, similar to a debounce but cooperative with React's scheduler. Use it when you can't wrap the state update itself (e.g., the value comes from a parent prop).
function FilteredList({ query }) {
const deferredQuery = useDeferredValue(query);
const filtered = useMemo(
() => expensiveFilter(deferredQuery),
[deferredQuery]
);
return <List items={filtered} />;
}
Suspense lets components declaratively wait for something before rendering. A component "suspends" by throwing a Promise. React catches it, renders the nearest <Suspense> boundary's fallback, and retries when the Promise resolves.
function UserProfile({ userId }) {
const user = use(fetchUser(userId)); // suspends if pending
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<Skeleton />}>
<UserProfile userId={1} />
</Suspense>
);
}
Suspense + Streaming SSR — in Next.js App Router (built on renderToPipeableStream), Suspense boundaries enable HTML streaming. Instead of waiting for all data before sending any HTML, the server streams the shell immediately, then streams HTML chunks for each Suspense boundary as its data resolves. The browser progressively renders rather than waiting for a single large payload.
This enables above-the-fold content to appear instantly while below-the-fold sections load progressively — without client-side JS waterfalls.
The React Compiler (stable in React 19 / Next.js 15) is a build-time Babel/SWC plugin that analyses React component code and automatically inserts useMemo, useCallback, and React.memo where they would be beneficial — eliminating manual memoisation.
The problem: React re-renders a component whenever its parent re-renders, even if props haven't changed. Developers must manually memoize, which is tedious, error-prone, and adds cognitive overhead.
// What you write:
function TodoList({ todos, filter }) {
const filtered = todos.filter(t => t.status === filter);
return filtered.map(t => <Todo key={t.id} todo={t} />);
}
// What the compiler generates (conceptually):
function TodoList({ todos, filter }) {
const filtered = useMemo(
() => todos.filter(t => t.status === filter),
[todos, filter]
);
return filtered.map(t => <Todo key={t.id} todo={t} />);
}
Requirement: Code must follow the Rules of React (no mutations of props/state, hooks only at top level). The compiler validates this and skips components it can't safely optimise. With the compiler, manual useMemo/useCallback becomes largely unnecessary in new code.
Hydration attaches React's event listeners and state to server-rendered HTML. React walks the server-rendered DOM and the client virtual DOM simultaneously, expecting them to match. If they don't, React logs an error and re-renders the subtree from scratch on the client — negating the performance benefit of SSR.
Common causes:
- Rendering
new Date()orMath.random()— values differ between server and client execution. - Conditional rendering based on
typeof window !== 'undefined'— server renders nothing, client renders something. - Browser extensions injecting DOM nodes.
- Incorrect HTML nesting (e.g.,
<p>inside<p>) — the browser auto-corrects, creating a mismatch.
// Wrong — different output on server vs client
function Clock() {
return <span>{new Date().toLocaleTimeString()}</span>;
}
// Correct — defer client-only rendering to after hydration
function Clock() {
const [time, setTime] = useState<string | null>(null);
useEffect(() => {
setTime(new Date().toLocaleTimeString());
const id = setInterval(
() => setTime(new Date().toLocaleTimeString()), 1000
);
return () => clearInterval(id);
}, []);
return <span>{time ?? '--:--:--'}</span>;
}
// For intentional mismatches (e.g. browser-injected content)
<div suppressHydrationWarning>{clientOnlyContent}</div>
Functional components don't have lifecycle methods — they use hooks that compose lifecycle behaviour more flexibly:
- Mount (
componentDidMount) →useEffect(() => { ... }, [])— empty array, runs once after first paint. - Update (
componentDidUpdate) →useEffect(() => { ... }, [dep])— runs after renders wheredepchanged. - Unmount (
componentWillUnmount) → the cleanup function returned fromuseEffect. - Before paint (
componentDidMountfor DOM reads) →useLayoutEffect— synchronous after DOM mutations but before the browser paints. Use for reading layout to avoid flicker.
useEffect(() => {
const sub = subscribe(userId); // mount / update
return () => sub.unsubscribe(); // cleanup / unmount
}, [userId]); // re-runs when userId changes
Key mental model: think of useEffect not as lifecycle methods but as "synchronise this side effect with these values." The cleanup runs before the next effect and on unmount. This model handles mount, update, and unmount in one composable unit.
Hooks Deep-Dive
10 questionsTwo rules: only call hooks at the top level (not inside conditionals, loops, or nested functions), and only call hooks from React functions (components or custom hooks).
Why: React tracks hook calls by their order in each render — each call corresponds to a slot in an internal linked list. React relies on call order being identical across renders to correctly associate each hook with its stored state.
// BROKEN — conditional hook changes the order
function Component({ showName }) {
const [count, setCount] = useState(0); // hook 1
if (showName) {
const [name, setName] = useState(''); // hook 2 (sometimes)
}
const [flag, setFlag] = useState(false); // reads slot 2 when showName=false → bug
}
Enable eslint-plugin-react-hooks in every project — it statically enforces both rules and should be a hard CI requirement.
useEffect dependency arrays in depth. What are stale closures and how do you fix them?A stale closure occurs when a useEffect captures a variable but its dependency array doesn't include it. The effect runs with a "frozen" snapshot of the variable from the render it was created in, even as the variable updates.
// Stale closure — count is always 0 inside the interval
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // always reads count=0 — stale!
}, 1000);
return () => clearInterval(id);
}, []); // empty deps — closes over count=0 forever
}
// Fix 1: functional updater — no closure dependency needed
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []); // safe
// Fix 2: include count in deps (restarts interval on each change)
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, [count]);
// Fix 3: useRef for values that must be current but shouldn't restart effects
const countRef = useRef(count);
useLayoutEffect(() => { countRef.current = count; });
useMemo and useCallback? When are they counterproductive?When they help:
- Referential stability for memoised children — passing a new function/object to a
React.memo-wrapped child on every render defeats memoisation.useCallbackkeeps the reference stable. - Expensive computations — filtering/sorting large lists, complex derived state.
useMemoavoids recomputing on unrelated renders. - Stabilising effect dependencies — if an object or function is a
useEffectdependency, wrap it so the effect doesn't re-run every render.
When they hurt or are pointless:
- Cheap computations — dependency checking and cache storage often cost more than the computation itself.
- Non-memoised parent —
useCallbackon a child's callback is useless if the parent re-renders on every state change anyway. - Premature optimisation — measure with React DevTools Profiler before adding. With the React Compiler, manual memoisation is largely unnecessary.
useReducer and when do you prefer it over useState?useReducer(reducer, initialState) manages state through a pure reducer(state, action) → newState function. It returns [state, dispatch].
Prefer useReducer when:
- Multiple sub-values update together — one dispatch atomically updates several fields.
- Next state depends on previous state in complex ways — the reducer makes logic explicit and testable.
- Transitions follow a finite state machine — invalid states become unrepresentable at the type level.
- Passing state-update logic deep into the tree —
dispatchis stable (same reference across renders) so it doesn't needuseCallback.
type State = { status: 'idle'|'loading'|'success'|'error'; data: User|null; error: string|null };
type Action = { type:'FETCH_START' } | { type:'FETCH_SUCCESS'; payload:User } | { type:'FETCH_ERROR'; payload:string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'FETCH_START': return { ...state, status:'loading', error:null };
case 'FETCH_SUCCESS': return { status:'success', data:action.payload, error:null };
case 'FETCH_ERROR': return { ...state, status:'error', error:action.payload };
default: return state;
}
}
useContext work and what are its performance pitfalls?useContext(MyContext) subscribes a component to the nearest Provider above it. When the context value changes, every consumer re-renders — React has no way to do partial context subscriptions.
Performance pitfall: A large, frequently-updated object in a single context causes every consumer to re-render on every change, even if the specific field they use didn't change.
// Problematic — one large context; theme change re-renders all consumers
const AppContext = createContext({ user, theme, cart, settings });
// Better — split by update frequency
const UserContext = createContext(user); // rarely changes
const ThemeContext = createContext(theme); // changes on toggle
const CartContext = createContext(cart); // changes on add/remove
Solutions: Split contexts by update frequency. Use useMemo to stabilise the value object. For high-frequency shared state, use Zustand or Jotai — they support granular subscriptions where components only re-render when the exact slice they subscribe to changes.
useRef beyond DOM access? How do you use it for mutable values across renders?useRef returns a mutable { current: value } object that persists for the component's lifetime. Mutating ref.current does not trigger a re-render — it's a side channel outside React's reactive system.
Use cases beyond DOM refs:
- Stable reference to latest callback — store the latest version of a callback without restarting effects (the
useLatestpattern). - Interval/timeout IDs — store cleanup handles without affecting renders.
- Previous value tracking — capture the previous render's props/state for comparison.
- Instance variables — any value that needs to persist across renders and be mutated synchronously.
// useLatest — always-current callback without restarting effects
function useLatest<T>(value: T) {
const ref = useRef(value);
useLayoutEffect(() => { ref.current = value; });
return ref;
}
function useDebounce(callback: (...args: any[]) => void, delay: number) {
const callbackRef = useLatest(callback);
return useMemo(
() => debounce((...args: any[]) => callbackRef.current(...args), delay),
[delay] // stable debounced fn — always calls the latest callback
);
}
useImperativeHandle and when is it the right tool?useImperativeHandle customises the value exposed to a parent via a ref. It exposes a controlled API surface rather than the entire DOM node, preventing parents from accessing internals they shouldn't touch.
// React 19 — ref is a regular prop (no forwardRef needed)
type VideoHandle = { play: () => void; pause: () => void; seek: (t: number) => void };
function VideoPlayer({ src, ref }: { src: string; ref: React.Ref<VideoHandle> }) {
const domRef = useRef<HTMLVideoElement>(null);
useImperativeHandle(ref, () => ({
play: () => domRef.current?.play(),
pause: () => domRef.current?.pause(),
seek: (t) => { if (domRef.current) domRef.current.currentTime = t; },
// The full HTMLVideoElement is intentionally NOT exposed
}), []);
return <video ref={domRef} src={src} />;
}
const playerRef = useRef<VideoHandle>(null);
<VideoPlayer src={url} ref={playerRef} />
<button onClick={() => playerRef.current?.play()}>Play</button>
Use sparingly — imperative handles break declarative data flow. Valid use cases: media players, animation triggers, canvas operations, focus management in complex form libraries. Prefer lifting state or callbacks for most cases.
Principles: Single responsibility, return a stable named-property object (not a positional tuple for 3+ values), accept an options object rather than many positional args, always clean up side effects.
function useFetch<T>(url: string) {
const [state, dispatch] = useReducer(fetchReducer<T>, {
status: 'idle', data: null, error: null,
});
useEffect(() => {
if (!url) return;
const controller = new AbortController();
dispatch({ type: 'FETCH_START' });
fetch(url, { signal: controller.signal })
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
.catch(err => {
if (err.name !== 'AbortError')
dispatch({ type: 'FETCH_ERROR', payload: err.message });
});
return () => controller.abort(); // cancel on url change or unmount
}, [url]);
return state; // { status, data, error }
}
Testing: Use renderHook from @testing-library/react. Mock the network layer with MSW (Mock Service Worker). Test state transitions by acting on the hook's returned functions.
use() in React 19? How does it change data fetching patterns?use(promise) is a React 19 hook that reads the value of a Promise or Context inside a component. Unlike all other hooks, use can be called inside conditionals and loops — it is not subject to the top-level-only rule.
When use(promise) encounters a pending Promise it suspends the component, triggering the nearest Suspense boundary. When the Promise resolves, React retries the render and use returns the resolved value.
// React 19 — async data without useEffect
function UserCard({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // suspends if pending
return <div>{user.name}</div>;
}
function App() {
const userPromise = fetchUser(userId); // initiate fetch early
return (
<Suspense fallback={<Skeleton />}>
<UserCard userPromise={userPromise} />
</Suspense>
);
}
// use() can also be conditional — valid!
if (isEnabled) {
const theme = use(ThemeContext);
}
This enables a simpler model: initiate fetches high in the render tree, pass promises as props, let Suspense handle loading states — no useEffect waterfalls or loading state management.
Server Actions are async functions marked with 'use server' that run exclusively on the server but can be called directly from Client Components — no API route needed. They integrate natively with HTML forms and the new useActionState hook.
// app/actions.ts — server only
'use server';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
await db.posts.create({ data: { title } });
revalidatePath('/posts');
return { success: true };
}
// app/new-post/page.tsx — Client Component
'use client';
import { useActionState } from 'react';
import { createPost } from '../actions';
export default function NewPostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<input name="title" required />
<button disabled={isPending}>
{isPending ? 'Saving…' : 'Create Post'}
</button>
{state?.success && <p>Created!</p>}
</form>
);
}
Works progressively — the form submits even without JavaScript. React handles pending states and error boundaries around the action. Eliminates boilerplate API routes for simple mutations.
State Management
8 questions- React Context — built-in, zero-dependency. Fine for low-frequency updates (theme, auth, locale). All consumers re-render on any change — bad for high-frequency global state.
- Zustand — minimal, selector-based. Components subscribe to slices and only re-render when that slice changes. No boilerplate. Works outside React. Best choice for most apps needing shared client state.
- Jotai — atomic state model. Each atom is a reactive unit; derived atoms compose. Fine-grained subscriptions. Integrates with Suspense natively. Excellent for interdependent state.
- Redux Toolkit (RTK) — opinionated, structured, battle-tested with excellent DevTools (time-travel, action replay). RTK Query handles server state. Worth the boilerplate for large teams needing strict conventions.
Decision framework:
- Server state (remote data, caching): TanStack Query / SWR — don't put server state in Zustand/Redux.
- Shared UI state, frequent updates: Zustand.
- Atomic interdependent state: Jotai.
- Large teams, strict conventions, time-travel debugging: Redux Toolkit.
- Infrequent global config: Context.
TanStack Query is an async state manager for server-fetched data. It separates server state from client state, recognising they have fundamentally different characteristics (stale, async, shared vs synchronous, local, owned).
- Query cache — every query is identified by a
queryKey. Results are cached in memory. Same-key calls share one in-flight request (deduplication). - Stale-while-revalidate — configurable
staleTimeandgcTime. Data is "stale" afterstaleTimepasses; React Query refetches in the background on window focus, mount, or network reconnect without blocking the UI.
const { data, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // treat as fresh for 5 minutes
});
const mutation = useMutation({
mutationFn: (newPost) => api.posts.create(newPost),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['posts'] }),
onMutate: async (newPost) => { // optimistic update
await queryClient.cancelQueries({ queryKey: ['posts'] });
const previous = queryClient.getQueryData(['posts']);
queryClient.setQueryData(['posts'], old => [...old, newPost]);
return { previous };
},
onError: (err, _, ctx) => queryClient.setQueryData(['posts'], ctx.previous),
});
Prop drilling passes props through intermediate components that don't use the data — they just forward it to a deeply nested child. Creates tight coupling and makes refactoring painful.
Solutions (in order of preference):
- Component composition — the most under-used solution. Pass rendered elements as
childrenor named slot props. The intermediate components don't need to know about the data at all. - Context — for data genuinely needed by many components at different nesting levels.
- External state manager — Zustand/Jotai; components subscribe directly without intermediaries.
// Prop drilling — Layout → Sidebar → Avatar all receive user
<Layout user={user}>
<Sidebar user={user}><Avatar user={user} /></Sidebar>
</Layout>
// Composition — Layout never knows about user
function UserPage({ user }) {
return (
<Layout
sidebar={<Sidebar avatar={<Avatar user={user} />} />}
/>
);
}
Prop drilling is acceptable 1–2 levels deep. Composition should be tried before reaching for Context. Context hides dependencies, making data flow less traceable — prop drilling is explicit.
Optimistic UI updates local state immediately when the user takes an action, before the server confirms. If the request fails, state rolls back. Users perceive instant feedback even on slow networks.
// React 19 useOptimistic — built-in optimistic state
function LikeButton({ postId, initialLikes }) {
const [likes, setLikesOptimistic] = useOptimistic(
initialLikes,
(current, delta) => current + delta
);
async function handleLike() {
setLikesOptimistic(1); // instantly +1 in UI
try {
await api.likePost(postId);
// on success: server-confirmed state takes over
} catch {
// on failure: React rolls back to initialLikes automatically
toast.error('Failed to like — please try again');
}
}
return <button onClick={handleLike}>❤️ {likes}</button>;
}
Requirements for safe optimistic UI: every update has a clear rollback path; use temporary client-side IDs for new items replaced with server IDs on success; disable the action while in-flight to prevent double-submit; design server APIs to be idempotent so retries don't duplicate side effects.
URL (query params, path params) is a form of global, shareable state. Filters, search terms, pagination, and selected tabs belong in the URL — anything a user should be able to bookmark or share.
Principle: treat the URL as the source of truth. Read from the URL; update the URL instead of local state.
// nuqs — type-safe URL state in Next.js
import { useQueryState, parseAsInteger, parseAsString } from 'nuqs';
function ProductFilters() {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [search, setSearch] = useQueryState('q', parseAsString.withDefault(''));
const [sort, setSort] = useQueryState('sort', parseAsString.withDefault('newest'));
// URL: /products?page=2&q=laptop&sort=price-asc
// Back button restores filter state automatically
}
Use replace (not push) for transient changes like typing in a search box — avoids polluting browser history. Server Components in Next.js App Router read searchParams synchronously on the server, enabling SSR of filtered/paginated pages without loading spinners.
Classic Redux problems: excessive boilerplate (separate files for actions, action creators, reducers, selectors), manual immutability (spread operators everywhere), no built-in async handling. RTK addresses all three.
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUser = createAsyncThunk(
'users/fetch',
async (id: string) => (await fetch(`/api/users/${id}`)).json()
);
const usersSlice = createSlice({
name: 'users',
initialState: { entities: {}, status: 'idle' as const },
reducers: {
userUpdated(state, action) {
// Immer allows "mutations" — converted to immutable updates under the hood
state.entities[action.payload.id] = action.payload;
},
},
extraReducers: builder => {
builder
.addCase(fetchUser.pending, state => { state.status = 'loading'; })
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'idle';
state.entities[action.payload.id] = action.payload;
});
},
});
RTK Query — a data-fetching and caching layer built into RTK. Defines endpoints declaratively; auto-generates hooks (useGetUserQuery, useCreatePostMutation). Handles loading/error states, cache invalidation, and optimistic updates. A full alternative to TanStack Query for teams already using Redux.
Zustand stores state in a plain JavaScript object outside React's component tree using a publish-subscribe pattern. Each useStore call registers a subscriber that triggers a re-render only when the selected slice changes (reference equality).
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
const useCartStore = create<CartStore>()(
devtools(persist(
(set) => ({
items: [],
total: 0,
addItem: (item) => set(state => ({
items: [...state.items, item],
total: state.total + item.price,
})),
clear: () => set({ items: [], total: 0 }),
}),
{ name: 'cart-storage' }
))
);
// Granular subscription — only re-renders when items.length changes
const itemCount = useCartStore(state => state.items.length);
Why it's lightweight (~1 KB): no Provider needed, no action/reducer split, no middleware required for async, selector-based subscriptions without extra libraries. The entire mental model is a single create call.
XState implements finite state machines in JavaScript. A machine defines a fixed set of states, the events that cause transitions, and actions that run on entry/exit. The machine can only be in one state at a time — impossible states become unrepresentable.
When a state machine is the right model:
- Complex async flows — a multi-step form (filling → validating → submitting → success/error) is error-prone with boolean flags but clean as a machine.
- Proliferating boolean flags —
isLoading && !isError && !isSuccesssignals an implicit machine. Make it explicit. - Strict sequencing — onboarding flows, wizards, checkout, game state.
const checkoutMachine = createMachine({
id: 'checkout', initial: 'cart',
states: {
cart: { on: { PROCEED: 'address' } },
address: { on: { BACK: 'cart', PROCEED: 'payment' } },
payment: { on: { BACK: 'address', SUBMIT: 'processing' } },
processing: {
invoke: {
src: 'processPayment',
onDone: { target: 'success' },
onError: { target: 'error', actions: assign({ error: (_, e) => e.data }) },
},
},
success: { type: 'final' },
error: { on: { RETRY: 'payment' } },
},
});
const [state, send] = useMachine(checkoutMachine);
// state.matches('processing') → show spinner
// send({ type: 'PROCEED' }) → transition
Rendering Patterns
6 questions- CSR (Client-Side Rendering) — server sends a minimal HTML shell; React renders everything in the browser. Fast TTFB, poor FCP and SEO. Use for: dashboards, authenticated apps where SEO doesn't matter.
- SSR (Server-Side Rendering) — HTML rendered on the server per request. Good FCP, good SEO, higher server CPU. Use for: personalised pages, fresh-data-required pages.
- SSG (Static Site Generation) — HTML pre-built at deploy time, served from CDN. Fastest TTFB and FCP, zero server compute per request. Use for: marketing pages, blogs, docs.
- ISR (Incremental Static Regeneration) — SSG with background revalidation (
revalidate: 60). Serves stale HTML immediately, regenerates in the background. Use for: product pages, news articles. - PPR (Partial Pre-Rendering) — Next.js 15 experimental. Pre-renders the static shell at build time; dynamic holes filled at request time via Suspense. One route gets CDN-speed static content and dynamic personalised sections simultaneously.
Decision: SSG + ISR first (best performance). SSR when you need per-request fresh personalised data. CSR only for fully private authenticated UIs. PPR when a page mixes static and dynamic sections.
Compound components — a set of components designed to work together, sharing implicit state via Context. The parent manages state; the children are composable building blocks that consumers arrange freely. Used by Radix UI, React Select, Headless UI.
const AccordionCtx = createContext(null);
function Accordion({ children }) {
const [openId, setOpenId] = useState(null);
return (
<AccordionCtx.Provider value={{ openId, setOpenId }}>
<div>{children}</div>
</AccordionCtx.Provider>
);
}
Accordion.Trigger = function Trigger({ id, children }) {
const { openId, setOpenId } = useContext(AccordionCtx);
return <button onClick={() => setOpenId(id === openId ? null : id)}>{children}</button>;
};
Accordion.Content = function Content({ id, children }) {
const { openId } = useContext(AccordionCtx);
return openId === id ? <div>{children}</div> : null;
};
Render props — a prop that is a function called by the component with its internal state, allowing the consumer to control rendering. Largely superseded by hooks, but still used in headless patterns and for consumers that need to render differently based on state without owning it.
<MouseTracker>
{({ x, y }) => <Cursor x={x} y={y} />}
</MouseTracker>
A Higher-Order Component (HOC) is a function that takes a component and returns an enhanced component. It shares cross-cutting behaviour (auth, logging, data fetching) without modifying the wrapped component.
function withAuth<P extends object>(WrappedComponent: React.ComponentType<P>) {
return function AuthenticatedComponent(props: P) {
const { user, isLoading } = useAuth();
if (isLoading) return <Spinner />;
if (!user) return <Redirect to="/login" />;
return <WrappedComponent {...props} />;
};
}
HOC problems: prop name collisions, wrapper hell in DevTools, opaque data flow.
When HOCs are still relevant:
- Error boundaries — still class-only in React 18;
withErrorBoundaryHOC wraps them cleanly. - Conditionally preventing rendering — hooks can't return early but HOCs can.
- Wrapping class components that can't use hooks.
For new code, prefer custom hooks — simpler, more testable, and explicit about data flow.
React.lazy and code splitting? How do you implement route-based and component-based splitting?React.lazy enables dynamic imports — a component's code is split into a separate chunk and loaded on demand rather than in the initial bundle, reducing initial JS payload and time-to-interactive.
import { lazy, Suspense } from 'react';
// Route-based — each page is a separate chunk
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
// Component-based — load heavy library on demand
const RichTextEditor = lazy(() => import('./RichTextEditor'));
function PostEditor({ mode }) {
return (
<Suspense fallback={<EditorSkeleton />}>
{mode === 'write' && <RichTextEditor />}
</Suspense>
);
}
// Preload on hover — chunk loads before the click
const preloadDashboard = () => import('./pages/Dashboard');
<Link onMouseEnter={preloadDashboard} to="/dashboard">Dashboard</Link>
In Next.js App Router, route-level code splitting is automatic. Use next/dynamic for component-level splitting with an ssr: false option for client-only components.
Error boundaries are class components implementing getDerivedStateFromError and/or componentDidCatch. They catch JavaScript errors in their child tree during rendering, lifecycle methods, and constructors — preventing the whole app from crashing.
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
logToSentry(error, info.componentStack);
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <ErrorFallback error={this.state.error} />;
}
return this.props.children;
}
}
<ErrorBoundary fallback={<p>Widget failed to load</p>}>
<DangerousWidget />
</ErrorBoundary>
Cannot catch: errors in event handlers (use try/catch), async code (setTimeout, fetch callbacks), SSR errors, or errors in the boundary itself. Use react-error-boundary library for a functional API with built-in reset capability.
Lifting state up moves state to the nearest common ancestor of all components that need to share it, creating a single source of truth.
Controlled components — the parent owns the state and passes value + onChange props. Maximum composability and testability — the component is a pure function of its props.
Uncontrolled components — the component manages its own state internally; the parent reads via a ref or onSubmit. Simpler for one-off cases (file inputs are always uncontrolled in React).
// Controlled — parent owns state (preferred)
function ControlledInput({ value, onChange }) {
return <input value={value} onChange={e => onChange(e.target.value)} />;
}
// Uncontrolled — component owns state, parent reads on submit
function UncontrolledInput({ defaultValue, onSubmit }) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<form onSubmit={() => onSubmit(inputRef.current?.value)}>
<input ref={inputRef} defaultValue={defaultValue} />
</form>
);
}
Design components as controlled by default. Provide an uncontrolled variant with a defaultValue for convenience. Libraries like Radix UI and React Hook Form follow this dual-mode approach.
Next.js & SSR
8 questionsThe App Router (Next.js 13+, stable 14) is a new routing system built on React Server Components, nested layouts, and React 18 streaming.
Key differences from the Pages Router:
- Server Components by default — every component in
app/is an RSC unless it has'use client'. - Nested layouts — each folder can have a
layout.tsxthat wraps child routes and persists across navigations (no unmount/remount). Pages Router has no native nested layout support. - Collocated data fetching — fetch data directly in Server Components with
async/await. NogetServerSidePropsorgetStaticProps. - File conventions —
page.tsx,layout.tsx,loading.tsx(Suspense),error.tsx(Error Boundary),not-found.tsx— all automatically wired by Next.js. - Route Handlers —
route.tsfiles use the Web-standard Request/Response API instead ofreq/resfrom Node.
app/
├── layout.tsx # Root layout (html, body)
├── page.tsx # / route
└── dashboard/
├── layout.tsx # Persistent sidebar (doesn't unmount on sub-nav)
├── page.tsx # /dashboard
├── loading.tsx # Shown while page.tsx loads (Suspense fallback)
└── settings/
└── page.tsx # /dashboard/settings
- Request Memoisation — within a single server render, identical
fetch()calls are deduplicated. Multiple components fetching the same URL share one HTTP request. Reset per request. - Data Cache — persistent server-side cache between requests. Configured via
cache: 'force-cache'ornext: { revalidate: 60 }. Invalidated withrevalidatePath()orrevalidateTag()from Server Actions. - Full Route Cache — statically rendered HTML and RSC payloads cached at build time for static routes. Bypassed for dynamic routes (those using
cookies(),headers(), orsearchParams). - Router Cache — client-side cache in the browser. Stores RSC payloads for visited routes, enabling instant back/forward navigation without a server round-trip.
// Opt out of data cache — always fetch fresh
fetch(url, { cache: 'no-store' });
// Time-based revalidation
fetch(url, { next: { revalidate: 3600 } }); // fresh for 1 hour
// Tag-based invalidation from a Server Action
fetch(url, { next: { tags: ['products'] } });
// In a Server Action:
import { revalidateTag } from 'next/cache';
revalidateTag('products'); // all fetches tagged 'products' are invalidated
Middleware-based protection — the recommended approach. middleware.ts runs on the Edge before any route handler. Check the session cookie/token and redirect unauthenticated users before the page renders.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from '@/lib/auth';
export async function middleware(request: NextRequest) {
const token = request.cookies.get('session')?.value;
const isProtected = request.nextUrl.pathname.startsWith('/dashboard');
if (isProtected && !token) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (isProtected && token) {
const payload = await verifyToken(token);
if (!payload) return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = { matcher: ['/dashboard/:path*', '/api/:path*'] };
Always add a second layer — check auth in the layout or page Server Component too. Never trust middleware alone for sensitive data rendering; the goal is defense in depth.
Auth libraries: Auth.js (NextAuth v5) is the standard with first-class App Router support. Clerk and Lucia are strong alternatives for different complexity tradeoffs.
next/image component do and how does it improve Core Web Vitals?next/image automatically: converts to WebP/AVIF (50% smaller), generates responsive srcset sizes, lazy-loads below-fold images, and requires width/height to reserve space and eliminate CLS.
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={630}
priority // above-the-fold: eager load + preload link injected
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
sizes="(max-width: 768px) 100vw, 50vw"
/>
CWV impact: priority flag injects a <link rel="preload"> for the hero image, improving LCP. Automatic space reservation eliminates CLS from images. WebP/AVIF reduces file size, improving LCP further. Lazy-loading reduces initial bandwidth, improving TTI.
For remote images, allowlist domains in next.config.js under images.remotePatterns. For static exports, configure a third-party loader (Cloudinary, Imgix).
Export a metadata object or generateMetadata async function from any page.tsx or layout.tsx. Next.js merges metadata through the route hierarchy and injects it into <head> during SSR.
// Static metadata
export const metadata: Metadata = {
title: { template: '%s | Acme Store', default: 'Acme Store' },
description: 'The best products in the world.',
openGraph: { images: ['/og-default.jpg'] },
};
// Dynamic metadata — fetched per request
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const product = await getProduct(params.slug);
return {
title: product.name,
description: product.description,
openGraph: { images: [product.imageUrl] },
};
}
// Dynamic OG images — no third-party service needed
// app/og/route.tsx
import { ImageResponse } from 'next/og';
export function GET() {
return new ImageResponse(
<div style={{ fontSize: 64, background: 'white' }}>My OG Image</div>,
{ width: 1200, height: 630 }
);
}
Parallel routes render multiple pages simultaneously in the same layout using @slot folder naming. Each slot has its own loading/error state. Used for dashboards with independent views or conditional slots (show team or analytics based on role).
app/
├── layout.tsx // receives both @team and @analytics as props
├── @team/page.tsx
└── @analytics/page.tsx
// layout.tsx
export default function Layout({ team, analytics }) {
return <div className="grid grid-cols-2">{team}{analytics}</div>;
}
Intercepting routes render a route within a different context than its full-page destination. The canonical use case is a photo modal: clicking a photo in a feed shows it in a modal (intercepted); navigating directly to the photo URL shows the full page. Defined with (.), (..), (...) conventions.
app/
├── photos/[id]/page.tsx # Full photo page (direct URL)
└── @modal/
└── (.)photos/[id]/page.tsx # Photo modal (when navigating from feed)
The recommended pattern is a [locale] dynamic segment at the root with middleware for language detection and redirection.
// app/[locale]/layout.tsx
export async function generateStaticParams() {
return [{ locale: 'en' }, { locale: 'de' }, { locale: 'fr' }];
}
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
const messages = await import(`../../messages/${locale}.json`);
return (
<NextIntlClientProvider locale={locale} messages={messages.default}>
{children}
</NextIntlClientProvider>
);
}
// middleware.ts — detect browser language and redirect to locale prefix
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const locales = ['en', 'de', 'fr'];
const hasLocale = locales.some(l => pathname.startsWith(`/${l}`));
if (!hasLocale) {
const locale = getLocale(request); // from Accept-Language header
return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url));
}
}
next-intl is the most popular library — provides useTranslations, type-safe message keys, number/date formatting, and works in both Server and Client Components.
Analyse first — use ANALYZE=true npm run build with @next/bundle-analyzer. Identify the biggest contributors before optimising.
- Server Components — move heavy libraries (markdown parsers, ORMs, date libraries) to RSCs. They contribute zero bytes to the client bundle.
- Tree shaking — import named exports:
import { format } from 'date-fns'not the whole library. - Dynamic imports — use
next/dynamicwithssr: falsefor heavy client-only components (rich text editors, chart libraries, maps). - Replace heavy libraries —
date-fnsinstead ofmoment,zodinstead ofjoi,clsxinstead ofclassnames. - Lazy third-party scripts — use
next/scriptwithstrategy="lazyOnload"for analytics, chat widgets, and tag managers.
// next/dynamic — client-only heavy component, no SSR
const Chart = dynamic(() => import('./HeavyChart'), {
ssr: false,
loading: () => <ChartSkeleton />,
});
// next.config.js — bundle analyser
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
TypeScript
8 questionstype and interface? When do you choose one over the other?- Declaration merging — interfaces can be declared multiple times and TypeScript merges them. Types cannot. Useful for augmenting library types (
Window,Express.Request). - Union types — only
typecan define unions:type Status = 'idle' | 'loading' | 'error'. - Computed / mapped types — only
typecan directly use mapped and conditional types. - Extends vs intersection —
interface extendschecks for conflicting properties at definition time;type &does not.
// type — unions, mapped types, conditional types, primitives
type Status = 'idle' | 'loading' | 'success' | 'error';
type Nullable<T> = T | null;
type ReadOnly<T> = { readonly [K in keyof T]: T[K] };
// interface — object shapes, declaration merging, class contracts
interface User { id: string; name: string; }
interface User { email: string; } // merged → all three fields
interface Admin extends User { role: 'admin'; }
class AdminImpl implements Admin { /* ... */ }
Convention: use interface for public API contracts where merging is useful. Use type for unions, primitives, and complex mapped/conditional types. Consistency within a codebase matters more than which you pick.
Result<T, E> type for error handling.Generics allow types to be parameterised — write logic once that works for many types while preserving type safety.
// Result type — Rust-inspired, no-throw error handling
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
async function fetchUser(id: string): Promise<Result<User, string>> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) return err(`HTTP ${res.status}`);
return ok(await res.json());
}
const result = await fetchUser('123');
if (result.ok) {
console.log(result.value.name); // typed as User
} else {
console.error(result.error); // typed as string
}
// Generic function with constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // return type is exactly T[K], not any
}
A discriminated union is a union of types sharing a common literal property (the discriminant). TypeScript narrows the type in control flow based on that property.
type Shape =
| { type: 'circle'; radius: number }
| { type: 'rectangle'; width: number; height: number }
| { type: 'triangle'; base: number; height: number };
function area(shape: Shape): number {
switch (shape.type) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'rectangle': return shape.width * shape.height;
case 'triangle': return 0.5 * shape.base * shape.height;
default:
// Exhaustiveness check — errors if a new variant is added without a case
const _exhaustive: never = shape;
throw new Error(`Unhandled: ${_exhaustive}`);
}
}
The never trick: if you add a new union member without a matching case, the default branch receives a non-never type and TypeScript reports an error at compile time. This is the most valuable safety pattern for extensible discriminated unions.
Partial, Pick, Omit, ReturnType, and Awaited.interface User { id: string; name: string; email: string; role: 'admin'|'user'; }
// Partial — all properties optional (for update/patch)
type UserUpdate = Partial<User>;
// Required — all optional → required
type StrictConfig = Required<Partial<Config>>;
// Pick — only selected properties
type UserCard = Pick<User, 'id' | 'name'>;
// { id: string; name: string }
// Omit — exclude selected properties
type PublicUser = Omit<User, 'role'>;
// { id: string; name: string; email: string }
// Record — object with keys K and values V
type RolePermissions = Record<User['role'], string[]>;
// { admin: string[]; user: string[] }
// ReturnType — infer a function's return type
type ParsedResult = ReturnType<typeof parseCSV>;
// Parameters — infer param types as a tuple
type FetchParams = Parameters<typeof fetch>;
// [input: RequestInfo, init?: RequestInit]
// Awaited — unwrap a Promise
type ResolvedUser = Awaited<ReturnType<typeof fetchUser>>;
DeepPartial<T> utility type.Conditional types — T extends U ? X : Y. Resolve to different types based on whether a type satisfies a constraint.
Mapped types — iterate over keys of a type and transform each property. The foundation of all built-in utility types.
// DeepPartial — recursively make all properties optional
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
// T extends object → conditional: is T an object?
// [K in keyof T]? → mapped: iterate keys, make optional
// DeepPartial<T[K]> → recursion: apply to nested objects
// : T → base case: primitives returned as-is
type Config = { server: { host: string; port: number }; timeout: number };
type PartialConfig = DeepPartial<Config>;
// { server?: { host?: string; port?: number }; timeout?: number }
// Conditional type with infer — extract inner type
type UnpackArray<T> = T extends Array<infer Item> ? Item : T;
type Str = UnpackArray<string[]>; // string
type Num = UnpackArray<number>; // number
import type { ReactNode, ComponentProps, MouseEvent } from 'react';
// Explicit interface (preferred over React.FC)
interface ButtonProps {
children: ReactNode;
variant?: 'primary' | 'secondary' | 'ghost';
isLoading?: boolean;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
}
function Button({ children, variant = 'primary', isLoading, onClick }: ButtonProps) { /* … */ }
// Extending HTML element — spread all native attributes
interface InputProps extends ComponentProps<'input'> {
label: string;
error?: string;
}
// Generic component
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return <ul>{items.map((item, i) => <li key={keyExtractor(item)}>{renderItem(item, i)}</li>)}</ul>;
}
// Generic custom hook
function useLocalStorage<T>(key: string, initial: T): [T, (v: T) => void] {
const [val, setVal] = useState<T>(() => {
try { return JSON.parse(localStorage.getItem(key) ?? '') as T; }
catch { return initial; }
});
const set = (v: T) => { setVal(v); localStorage.setItem(key, JSON.stringify(v)); };
return [val, set];
}
satisfies in TypeScript and when does it help over a direct type annotation?satisfies (TypeScript 4.9) validates that a value matches a type without widening the inferred type. With a direct annotation, TypeScript widens to the annotation, losing specific literal information.
type Palette = { [key: string]: string | [number, number, number] };
// Direct annotation — widens to the union; TypeScript loses which variant
const palette: Palette = { red: [255, 0, 0], blue: '#0000ff' };
palette.red.toUpperCase(); // ❌ TS error — could be string | tuple
// satisfies — validates shape but preserves the inferred type
const palette2 = {
red: [255, 0, 0],
blue: '#0000ff',
} satisfies Palette;
palette2.red.map(x => x * 2); // ✅ TypeScript knows red is a tuple
palette2.blue.toUpperCase(); // ✅ TypeScript knows blue is a string
// Config validation without losing key-specific types
const config = {
port: 3000,
host: 'localhost',
} satisfies Partial<ServerConfig>;
config.port.toFixed(); // ✅ port is number, not number | undefined
Zod defines schemas that validate data at runtime and automatically infer static TypeScript types — a single source of truth for both runtime and compile-time safety.
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user', 'moderator']),
createdAt: z.coerce.date(), // coerces ISO string → Date
});
type User = z.infer<typeof UserSchema>; // stays in sync automatically
async function fetchUser(id: string): Promise<User> {
const raw = await fetch(`/api/users/${id}`).then(r => r.json());
return UserSchema.parse(raw); // throws ZodError if invalid
}
// Safe parse — returns success/error object
const result = UserSchema.safeParse(formData);
if (result.success) {
doSomethingWith(result.data); // typed as User
} else {
console.error(result.error.format()); // structured error messages
}
// React Hook Form integration
import { zodResolver } from '@hookform/resolvers/zod';
const { register, handleSubmit, formState: { errors } } = useForm<User>({
resolver: zodResolver(UserSchema),
});
Performance
8 questions- LCP (Largest Contentful Paint) — time until the largest above-fold element renders. Target: <2.5s. Improve by: preloading the hero image (
fetchpriority="high"), SSR/SSG instead of CSR, optimising TTFB, CDN, next-gen image formats. - INP (Interaction to Next Paint) — responsiveness to user interactions. Target: <200ms. Improve by: breaking long tasks (yield with
scheduler.yield()), removing unnecessary JS in event handlers, usingstartTransitionfor non-urgent updates, web workers for heavy computation. - CLS (Cumulative Layout Shift) — unexpected layout shifts. Target: <0.1. Improve by: setting
width/heighton images, reserving space for ads/embeds, using CSStransforminstead of layout-triggering properties for animations.
// Report CWV from real users
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(m => sendToAnalytics('lcp', m.value));
onINP(m => sendToAnalytics('inp', m.value));
onCLS(m => sendToAnalytics('cls', m.value));
Tools: Lighthouse (lab), PageSpeed Insights (field data from CrUX), WebPageTest. Always measure with field data — lab metrics miss real-user conditions (device diversity, slow connections, browser extensions).
Step 1 — Identify unnecessary re-renders: React DevTools Profiler → Record → interact → look for components rendering too frequently. Enable "Highlight updates" to visualise re-renders.
Step 2 — Fix: React.memo for pure components, useCallback for stable callbacks, useMemo for expensive derivations, split Context, move state down.
Step 3 — Profile JS execution: Chrome DevTools Performance panel → record interaction → flame chart shows where JS time is spent. Look for long tasks (>50ms) on the main thread.
Step 4 — Check bundle size: @next/bundle-analyzer or vite-bundle-visualizer.
// Quick win — enable AQE and Kryo before profiling
spark.conf.set("spark.sql.adaptive.enabled", "true") // wrong guide!
// Correct: quick wins for React perf
// 1. Enable React Compiler (auto-memoization)
// 2. Use React DevTools Profiler to find hot components
// 3. Check for prop instability in useEffect deps
// 4. Virtualise long lists with @tanstack/react-virtual
// Development aid — log which props changed caused a re-render
if (process.env.NODE_ENV === 'development') {
const { whyDidYouRender } = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, { trackAllPureComponents: true });
}
Virtual scrolling renders only items currently visible in the viewport plus a small buffer. As the user scrolls, off-screen items unmount and new ones mount. The DOM always contains ~20–50 rows regardless of the total list size.
When you need it: rendering 1,000+ DOM nodes causes slow initial render, high memory usage, and janky scrolling. The threshold depends on item complexity.
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: Product[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72, // estimated item height in px
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(vItem => (
<div
key={vItem.key}
style={{ position: 'absolute', top: 0, width: '100%',
transform: `translateY(${vItem.start}px)`, height: vItem.size }}
>
<ProductRow item={items[vItem.index]} />
</div>
))}
</div>
</div>
);
}
Libraries: @tanstack/react-virtual (headless, most flexible), react-window (lightweight), react-virtuoso (variable-height, chat-style, grouping).
The browser render pipeline: JS → Style → Layout → Paint → Composite.
- Layout (Reflow) — compute geometry. Triggered by:
width,height,margin,padding, font-size changes, DOM insertions, or reading layout properties (offsetWidth,getBoundingClientRect). - Paint — fill pixels. Triggered by:
color,background,box-shadow,border-radiuschanges. - Composite — combine layers on GPU. Triggered only by:
transform,opacity,filter. Happens off the main thread — the cheapest operation.
Performance implications: animations on transform/opacity only trigger composite → smooth 60fps. Animations on width/top/left trigger layout every frame → janky.
// Layout thrashing — forces reflow on each iteration
elements.forEach(el => {
const h = el.offsetHeight; // read → forces reflow
el.style.height = h * 2 + 'px'; // write
const newH = el.offsetHeight; // read again → forces another reflow!
});
// Fixed — batch reads first, then writes
const heights = elements.map(el => el.offsetHeight); // all reads
elements.forEach((el, i) => { el.style.height = heights[i] * 2 + 'px'; });
A web worker runs JavaScript in a background thread separate from the main thread. Long computations (data parsing, image processing, encryption) in a worker don't block the UI. Workers have no DOM access; communication is via postMessage / onmessage.
// worker.ts
self.addEventListener('message', ({ data }) => {
if (data.type === 'PARSE_CSV') {
const result = parseMillionRows(data.payload);
self.postMessage({ type: 'DONE', result });
}
});
// useWorker.ts — hook wrapping the worker
function useCSVParser() {
const workerRef = useRef<Worker>(null);
const [result, setResult] = useState(null);
const [isParsing, setIsParsing] = useState(false);
useEffect(() => {
workerRef.current = new Worker(new URL('./worker.ts', import.meta.url));
workerRef.current.onmessage = ({ data }) => {
if (data.type === 'DONE') { setResult(data.result); setIsParsing(false); }
};
return () => workerRef.current?.terminate();
}, []);
const parse = (csv: string) => {
setIsParsing(true);
workerRef.current?.postMessage({ type: 'PARSE_CSV', payload: csv });
};
return { parse, result, isParsing };
}
Use comlink library to wrap workers with a Proxy, making cross-thread calls feel synchronous. Vite and Next.js both support the new URL('./worker.ts', import.meta.url) syntax for proper bundling.
preload, prefetch, preconnect) and when do you use each?preload— fetch a resource ASAP for the current page at high priority. Use for: LCP hero images, above-fold fonts, critical JS discovered late in parsing.<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">prefetch— fetch a resource in idle time for a future navigation. Low priority, doesn't block. Use for: the next page's JS when the user is likely to navigate.<link rel="prefetch" href="/next-page.js">preconnect— establish DNS + TCP + TLS handshake to an origin without fetching. Saves 100–500ms on the first request. Use for: critical third-party origins (CDN, fonts, API).<link rel="preconnect" href="https://fonts.googleapis.com">dns-prefetch— DNS-only. Lighter than preconnect for non-critical origins.modulepreload— preloads an ES module and its dependency tree. Reduces waterfall for split JS chunks.
Next.js automatically injects preload links for priority images and prefetches route chunks on Link hover. In custom setups, use the Performance Budget tools to validate that preloads aren't causing bandwidth contention.
The island architecture renders pages as static HTML by default, with small "islands" of interactive components hydrated on demand. The rest is pure HTML/CSS — zero JavaScript overhead for non-interactive sections.
Why it's faster than traditional SSR: traditional SSR still ships the entire framework JS bundle for hydration. Even a 90% static page pays to parse/execute ~40–100 KB of React. Islands ship JS only for interactive parts.
<!-- Astro — island architecture -->
<!-- Static HTML — zero JS sent -->
{products.map(p => <ProductCard product={p} />)}
<!-- Interactive island — only this component is hydrated -->
<AddToCart
client:visible <!-- hydrate when entering viewport -->
productId={product.id}
/>
<!-- Other hydration directives:
client:load — immediately
client:idle — when browser is idle
client:media — when media query matches
-->
Next.js RSCs implement a similar model — Server Components are the static islands, Client Components are the interactive islands. The key difference: Next.js Client Components hydrate the full client tree; Astro ships zero JS for non-island components.
Infinite scroll uses an IntersectionObserver to detect when a sentinel element at the bottom enters the viewport, triggering the next page fetch.
import { useInfiniteQuery } from '@tanstack/react-query';
import { useRef, useEffect } from 'react';
function InfiniteProductList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['products'],
queryFn: ({ pageParam = null }) => fetchProducts({ cursor: pageParam }),
getNextPageParam: last => last.nextCursor ?? undefined,
});
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && hasNextPage && !isFetchingNextPage)
fetchNextPage();
}, { threshold: 0.5 });
observer.observe(el);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const allProducts = data?.pages.flatMap(p => p.items) ?? [];
return (
<>
{allProducts.map(p => <ProductCard key={p.id} product={p} />)}
<div ref={sentinelRef}>
{isFetchingNextPage && <Spinner />}
{!hasNextPage && <p>All products loaded</p>}
</div>
</>
);
}
Infinite scroll vs pagination: Infinite scroll suits exploratory browsing (feeds, images). Pagination suits finding a specific item or sharing a direct link to a page. Always use URL-based state for pagination (?page=3) so the back button and sharing work correctly.
Testing
7 questionsThe testing trophy (Kent C. Dodds) adjusts the classic pyramid for frontend: the most valuable tests are integration tests — they test multiple units working together as a user would, giving high confidence without the brittleness of over-mocked unit tests or the slowness of E2E.
- Static analysis (TypeScript, ESLint) — catches trivial bugs at zero runtime cost. Always include.
- Unit tests — pure functions, custom hooks, utilities. Don't unit test components in isolation from each other — you end up testing implementation details.
- Integration tests (largest proportion) — render a full feature (form, page section) with real component interactions and mocked network. Test behaviour, not implementation.
- E2E tests — critical user journeys (signup, checkout) against a real browser. High confidence but slow. Keep to the minimum happy paths.
RTL's principle: "The more your tests resemble the way your software is used, the more confidence they can give you." Find elements by accessible role/label/text, interact with them, assert on the DOM.
Enzyme focuses on implementation details — shallow rendering, accessing component state directly, calling lifecycle methods. Tests that inspect wrapper.state('count') break when you refactor internals even if behaviour is unchanged.
// Enzyme — brittle, tests implementation
const wrapper = shallow(<Counter />);
wrapper.find('button').simulate('click');
expect(wrapper.state('count')).toBe(1); // inspects internal state
// RTL — resilient, tests behaviour
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('increments counter on click', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
MSW (Mock Service Worker) intercepts fetch at the network level using a Service Worker (browser) or @msw/node (Node). Tests exercise the real fetch/TanStack Query logic without a real server, and without brittle module mocks.
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen } from '@testing-library/react';
const server = setupServer(
http.get('/api/users/1', () =>
HttpResponse.json({ id: '1', name: 'Alice', email: 'alice@test.com' })
)
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('renders user profile after fetch', async () => {
render(<QueryClientProvider client={queryClient}><UserProfile userId="1" /></QueryClientProvider>);
expect(screen.getByRole('status')).toBeInTheDocument(); // loading
await screen.findByText('Alice'); // findBy* waits for element to appear
expect(screen.getByText('alice@test.com')).toBeInTheDocument();
});
test('shows error on network failure', async () => {
server.use(http.get('/api/users/1', () => HttpResponse.error()));
render(/* ... */);
await screen.findByRole('alert');
});
RTL's query priority (most to least preferred):
- getByRole — accessible role (
button,textbox,heading). Also validates the element has the correct ARIA role — tests accessibility as a side effect. - getByLabelText — for form fields with a label. Mirrors how screen readers find inputs.
- getByPlaceholderText — fallback for inputs without visible labels.
- getByText — visible text content. Good for buttons, links, paragraphs.
- getByDisplayValue — current value of form elements.
- getByAltText — image alt text.
- getByTitle — title attribute (rarely accessible).
- getByTestId — last resort. Adds
data-testidto markup, coupling tests to implementation details.
// Bad — couples to CSS class
screen.getByClassName('submit-button');
// Bad — test-id as first choice
screen.getByTestId('submit-btn');
// Good — accessible, resilient
screen.getByRole('button', { name: /submit order/i });
screen.getByLabelText(/email address/i);
// Async — findBy* variants wait (up to 1s by default)
await screen.findByRole('heading', { name: /welcome/i });
- Playwright — tests run in Node, controlling Chromium/Firefox/WebKit via DevTools protocol. True parallel execution across browsers. Promise-based
awaitAPI. Supports multiple browser contexts per test. Strong CI support. - Cypress — JS runs in-browser alongside the app. Excellent time-travel debugger (screenshots at each step). Automatic waiting built in. Large plugin ecosystem. Single browser per run historically.
// Playwright test
import { test, expect } from '@playwright/test';
test('user can complete checkout', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Add to cart' }).click();
await page.getByRole('link', { name: 'Cart (1)' }).click();
await page.getByRole('button', { name: 'Checkout' }).click();
await page.getByLabel('Email').fill('test@example.com');
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
Recommendation: Playwright for new projects — faster, true cross-browser, better parallelism, more powerful API. Cypress if your team has existing investment and values the visual debugging experience.
Use renderHook from @testing-library/react — it provides a minimal component wrapper so hooks can be called and their output asserted without building a full UI.
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('increments and resets counter', () => {
const { result } = renderHook(() => useCounter({ initialValue: 5 }));
expect(result.current.count).toBe(5);
act(() => result.current.increment());
expect(result.current.count).toBe(6);
act(() => result.current.reset());
expect(result.current.count).toBe(5);
});
// Hook with context — provide wrapper
test('useCart adds items', () => {
const wrapper = ({ children }) => <CartProvider>{children}</CartProvider>;
const { result } = renderHook(() => useCart(), { wrapper });
act(() => result.current.addItem({ id: '1', price: 10 }));
expect(result.current.items).toHaveLength(1);
expect(result.current.total).toBe(10);
});
act() wraps code that triggers React state updates — it flushes effects and ensures the tree is consistent before making assertions.
Storybook is an isolated UI component explorer. You write "stories" — individual component states rendered in isolation — and browse them in a standalone UI. It serves as living documentation, a design system catalogue, and a development environment without running the full app.
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
};
export default meta;
export const Primary: StoryObj<typeof Button> = {
args: { variant: 'primary', children: 'Click me' },
};
export const Loading: StoryObj<typeof Button> = {
args: { isLoading: true, children: 'Save' },
};
// Play function — interaction test within the story
export const Submittable: StoryObj<typeof Button> = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
await expect(canvas.getByText('Saved!')).toBeInTheDocument();
},
};
Key addons: Interactions (plays tests in Storybook UI), Accessibility (runs aXe on every story automatically), Chromatic (visual regression CI). Together these make Storybook a comprehensive component quality gate — documentation, interaction testing, a11y, and visual regression in one place.
CSS & Styling
7 questions- CSS-in-JS (styled-components, Emotion) — colocates styles with components, dynamic styles via props, scoped by default. Runtime versions inject styles on the client — adds JS execution cost and is incompatible with RSC (they require client-side React context). Zero-runtime alternatives (Vanilla Extract, Linaria, Panda CSS) compile to CSS at build time — RSC-compatible.
- CSS Modules — locally scoped class names at build time, no runtime cost, plain CSS. Works with SSR and RSC. No dynamic styling without inline styles. Verbose — one file per component.
- Tailwind CSS — utility-first, atomic CSS generated at build time from usage scanning. Tiny production CSS. No naming decisions. Co-located in JSX. Initial learning curve; pairs excellently with
cva(class-variance-authority) andshadcn/uifor reusable typed variants.
Modern trend: Tailwind + shadcn/ui (Radix UI primitives + Tailwind) is the dominant stack for new Next.js projects. CSS Modules for teams preferring explicit CSS. Zero-runtime CSS-in-JS (Vanilla Extract) for design systems needing full theming power without runtime.
:where(), @layer) simplify it.Specificity determines which rule wins when multiple rules apply. Calculated as (inline, ID, class/pseudo-class/attr, element): #id = (0,1,0,0), .class = (0,0,1,0), element = (0,0,0,1).
:where() — like :is() but always contributes zero specificity. Ideal for resets and base styles that should be easy to override without specificity wars:
:where(h1, h2, h3) { margin: 0 } /* zero specificity — any class overrides it */
@layer — cascade layers give explicit ordering to style groups, independent of specificity. Higher-layer styles always win over lower-layer styles even at lower specificity — eliminating the need to escalate specificity to override library styles:
@layer reset, base, components, utilities;
@layer reset { *, *::before, *::after { box-sizing: border-box; } }
@layer components { .card { border-radius: 8px; } }
@layer utilities { .text-lg { font-size: 1.125rem; } }
/* utilities always beat components — no specificity games */
- Flexbox — one-dimensional (row OR column). Items determine their own size; the container aligns them. Best for: navbars, button groups, centering, any layout along a single axis.
- CSS Grid — two-dimensional (rows AND columns simultaneously). The container defines the structure. Best for: page layouts, card grids, dashboards — any layout where both dimensions need control at once.
/* Flexbox — navbar */
.nav { display: flex; align-items: center; gap: 1rem; }
.nav-logo { margin-right: auto; } /* push remaining items right */
/* Grid — responsive card grid */
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
/* Grid — holy grail page layout */
.page {
display: grid;
grid-template:
"header header " 64px
"sidebar main " 1fr
"footer footer " auto
/ 240px 1fr;
min-height: 100vh;
}
.header { grid-area: header; }
.sidebar { grid-area: sidebar; }
.main { grid-area: main; }
.footer { grid-area: footer; }
They compose well: use Grid for macro layout (page structure), Flexbox for micro layout (items within a cell). This is the most common real-world pattern.
CSS custom properties are defined with --name: value and accessed with var(--name, fallback). Unlike preprocessor variables (Sass), CSS variables are live — they can be changed at runtime from JavaScript, participate in the cascade, and are inherited by child elements.
/* Design token layer */
:root {
--color-primary: #6c63ff;
--color-bg: #0a0a0f;
--radius-card: 12px;
}
/* Theme switching via data attribute */
[data-theme="light"] {
--color-primary: #5548f0;
--color-bg: #f4f4f9;
}
/* Components inherit tokens */
.card {
background: var(--color-bg);
border-radius: var(--radius-card);
}
/* Runtime theming from JavaScript — no React re-render needed */
document.documentElement.style.setProperty('--color-primary', userColor);
/* Component-scoped overrides — narrow the cascade */
.sidebar { --color-bg: #111; } /* cards inside sidebar get this bg */
CSS variables enable true runtime theming — toggling a data-theme attribute changes all themed elements instantly via the cascade without any React re-renders. This is how most design systems implement theming today without CSS-in-JS.
Compositor-only animations — animate only transform and opacity. These run on the GPU in a separate thread — smooth 60fps even on slow devices. Avoid animating width, height, top, left — they trigger layout on every frame.
/* Safe — compositor only */
.card { transition: transform 200ms ease, opacity 200ms ease; }
.card:hover { transform: translateY(-4px); }
Framer Motion — the standard for complex React animations:
import { motion, AnimatePresence } from 'framer-motion';
// Layout animation — animates size/position changes automatically
<motion.div layout>{expandedContent}</motion.div>
// Exit animation — works when component unmounts (React normally can't)
<AnimatePresence>
{isVisible && (
<motion.div
key="modal"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2 }}
>
{content}
</motion.div>
)}
</AnimatePresence>
// Respect reduced motion
import { useReducedMotion } from 'framer-motion';
const shouldReduce = useReducedMotion();
const variants = shouldReduce ? {} : { y: [-20, 0], opacity: [0, 1] };
Media queries respond to viewport size. The problem: the same card component in a 3-column grid and a 1-column layout have the same viewport width — you can't style them differently with media queries alone.
Container queries (baseline 2023, all modern browsers) let elements respond to the size of their container, not the viewport. Components become truly self-contained and reusable regardless of placement.
/* Make the parent a container */
.card-wrapper {
container-type: inline-size;
container-name: card;
}
/* Card adapts to its container's width, not the viewport */
.card { display: flex; flex-direction: column; }
@container card (min-width: 400px) {
.card {
flex-direction: row; /* side-by-side when container is wide */
}
.card-image { width: 200px; flex-shrink: 0; }
}
/* Container query units — cqw, cqh, cqi */
.card-title { font-size: clamp(1rem, 4cqi, 1.5rem); }
Container queries are the missing piece for design systems. Components can be developed in isolation and placed anywhere without breakpoint coordination at the layout level.
Tailwind v3+ uses a Just-In-Time (JIT) compiler via PostCSS. Instead of shipping a ~3 MB full stylesheet, it scans source files for class name strings and generates only the CSS for classes actually used — producing 5–20 KB for a full app.
How scanning works: Tailwind uses a simple regex (not a full parser) to extract class-like strings from JSX, HTML, and JS. This means class names must appear as complete, unbroken strings in source code.
// WRONG — Tailwind can't detect this; class never appears in source
const color = 'red';
<div className={`bg-${color}-500`}> // ❌ split string
// CORRECT — full class names present in source code
const classMap = { red: 'bg-red-500', blue: 'bg-blue-500' };
<div className={classMap[color]}> // ✅
// Safelist dynamic classes explicitly
// tailwind.config.ts
safelist: [
'bg-red-500', 'bg-green-500',
{ pattern: /^text-(sm|base|lg)$/ },
]
Arbitrary values (w-[347px], text-[#ff6b6b]) and modifier stacking (hover:dark:text-[#ff6b6b]) are generated on demand — the JIT engine can handle any combination without pre-generating them all.
Browser & Web APIs
8 questionsJavaScript is single-threaded. The event loop: when the call stack is empty, drain the microtask queue completely, then pick the next task from the macrotask queue.
- Call stack — currently executing synchronous code.
- Microtask queue — higher priority, drained fully before next task or browser paint. Sources:
Promise.then/catch/finally,queueMicrotask(),MutationObserver. - Task (macrotask) queue — one task per event loop tick. Sources:
setTimeout,setInterval, I/O events, UI events (click).
console.log('1'); // sync
setTimeout(() => console.log('2'), 0); // macrotask
Promise.resolve().then(() => {
console.log('3'); // microtask
Promise.resolve().then(() => console.log('4')); // nested microtask
});
console.log('5'); // sync
// Output: 1, 5, 3, 4, 2
Why it matters for React: useLayoutEffect runs synchronously after DOM mutations (before paint, in the "after microtasks" phase). useEffect runs asynchronously after paint (scheduled as a task). State updates in setTimeout now batch in React 18 (automatic batching), matching React event handler behaviour.
localStorage, sessionStorage, IndexedDB, and cookies?- localStorage — synchronous key-value (strings), ~5–10 MB, persists until cleared, same-origin only, never sent to server. Good for: user preferences, theme, non-sensitive UI state. XSS-accessible — never store auth tokens here.
- sessionStorage — same as localStorage but cleared when the tab closes. Per-tab isolation. Good for: per-session wizard state, temp form drafts.
- IndexedDB — async, structured data (objects, blobs), no practical size limit, transactional, queryable. Correct for: offline PWAs, large datasets, client-side search indexes. Use
idbor Dexie.js as wrappers over the verbose native API. - Cookies — automatically sent with every request to the server. 4 KB limit. Critical flags:
HttpOnly(not JS-accessible — prevents XSS token theft),Secure(HTTPS only),SameSite=Strict(CSRF protection). The only client storage the server can read — use for auth session tokens.
Security rule: store auth tokens in HttpOnly Secure SameSite=Strict cookies, not localStorage. JS can never read HttpOnly cookies; XSS cannot steal them.
The same-origin policy blocks a page from reading responses from a different origin (scheme + host + port) via fetch. CORS allows servers to opt-in with response headers:
Access-Control-Allow-Origin: https://myapp.com— allow specific origin. Never use*with credentials.Access-Control-Allow-Methods: GET, POST, PUTAccess-Control-Allow-Headers: Content-Type, AuthorizationAccess-Control-Allow-Credentials: true— allows cookies (requires non-*origin).
Non-simple requests trigger a preflight OPTIONS request — the browser sends it automatically. Cache preflights with Access-Control-Max-Age to reduce overhead.
// Next.js Route Handler
export async function GET(request: Request) {
const origin = request.headers.get('origin') ?? '';
const allowed = ['https://myapp.com', 'https://staging.myapp.com'];
return new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': allowed.includes(origin) ? origin : 'null',
'Access-Control-Allow-Credentials': 'true',
},
});
}
Intersection Observer asynchronously observes when a target enters or leaves the viewport (or a parent element). It replaces scroll event listeners — which fire dozens of times per second and force layout — with a callback that runs only when visibility changes.
function useInView(options?: IntersectionObserverInit) {
const ref = useRef<HTMLElement>(null);
const [isInView, setIsInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => setIsInView(entry.isIntersecting),
{ threshold: 0.5, ...options }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return { ref, isInView };
}
// Lazy-load heavy component when it enters viewport
function LazySection() {
const { ref, isInView } = useInView({ rootMargin: '100px' });
return <section ref={ref}>{isInView && <HeavyChart />}</section>;
}
// Analytics — fire once when section becomes visible
useEffect(() => {
if (isInView) analytics.track('section_viewed', { section: 'pricing' });
}, [isInView]);
- WebSockets — full-duplex, bidirectional communication over a single TCP connection. Client and server send messages to each other any time. Use for: chat, collaborative editing, multiplayer games, live trading — anything requiring two-way real-time communication.
- Server-Sent Events (SSE) — server pushes data over standard HTTP. One-directional (server → client). Auto-reconnection built in. Works through CDNs and proxies without WebSocket upgrade support. Use for: live feeds, notifications, progress updates, LLM token streaming.
// SSE — streaming LLM response
export async function GET(request: Request) {
const stream = new TransformStream();
const writer = stream.writable.getWriter();
const encoder = new TextEncoder();
(async () => {
for await (const chunk of llm.streamResponse(prompt)) {
await writer.write(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
await writer.close();
})();
return new Response(stream.readable, {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
});
}
// Client
const source = new EventSource('/api/stream');
source.onmessage = (e) => setContent(prev => prev + JSON.parse(e.data).text);
SSE is simpler and sufficient for server-push-only scenarios. Many LLM chat UIs use SSE for token streaming — it works over standard HTTP/2 with no special infrastructure.
The critical rendering path: HTML parse → DOM → CSSOM → Render tree → Layout → Paint → Composite. The browser can't paint until both DOM and CSSOM are complete.
What blocks rendering:
- Render-blocking CSS —
<link rel="stylesheet">blocks painting until downloaded and parsed. Fix: inline critical CSS in<style>in the<head>; load non-critical CSS asynchronously. - Parser-blocking JS —
<script src>withoutasyncordeferpauses HTML parsing. Always usedeferfor non-critical scripts;asyncfor independent scripts. - Web fonts — FOIT while loading. Fix:
font-display: swapand preload critical fonts.
<!-- Optimised head -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<style>/* above-fold critical CSS inlined */</style>
<link rel="stylesheet" href="/main.css" media="print" onload="this.media='all'">
<script src="/analytics.js" async></script> <!-- non-critical, independent -->
<script src="/app.js" defer></script> <!-- main app, after HTML -->
CSP is an HTTP response header that tells the browser which script/style/image sources are allowed. It is the most effective defence against XSS — even if an attacker injects a <script> tag, CSP blocks it from executing if it's from an untrusted source.
// middleware.ts — generate per-request nonce (prevents cached CSP bypass)
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' https://vercel.live;
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data: https://cdn.example.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
upgrade-insecure-requests;
`.replace(/\s+/g, ' ').trim();
const response = NextResponse.next({
request: { headers: new Headers({ ...request.headers, 'x-nonce': nonce }) },
});
response.headers.set('Content-Security-Policy', csp);
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
return response;
}
Start with Content-Security-Policy-Report-Only to detect violations without blocking, then switch to enforcing. Nonces allow specific inline scripts (Next.js runtime) while blocking attacker-injected ones.
- XSS (Cross-Site Scripting) — attacker injects scripts that execute in users' browsers. Prevention: React auto-escapes JSX output. Use
dangerouslySetInnerHTMLonly with DOMPurify-sanitised content. Strict CSP blocks injected scripts at the browser level. Validate/sanitise all user input server-side. - CSRF (Cross-Site Request Forgery) — attacker's page triggers authenticated requests using victim's cookies. Prevention:
SameSite=StrictorLaxcookies block cross-site cookies in modern browsers. Next.js Server Actions include built-in CSRF protection (origin check). - Clickjacking — embed your page in an
<iframe>, overlay invisible buttons. Prevention:X-Frame-Options: DENYorframe-ancestors 'none'in CSP.
// Essential security headers (next.config.ts)
async headers() {
return [{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=()' },
{ key: 'Strict-Transport-Security',value: 'max-age=63072000; includeSubDomains; preload' },
],
}];
}
Accessibility
7 questionsWCAG (Web Content Accessibility Guidelines) is the international standard. WCAG 2.1/2.2 is the current baseline.
POUR principles:
- Perceivable — information must be presentable in ways users can perceive (alt text, captions, sufficient contrast).
- Operable — UI must be operable (keyboard accessible, no seizure-inducing content, enough time).
- Understandable — content must be understandable (readable, predictable, error identification).
- Robust — content must work reliably with assistive technologies (valid HTML, correct ARIA).
Conformance levels:
- Level A — minimum. Required by most accessibility laws.
- Level AA — the standard target. Required by ADA, EU EN 301 549, most government regulations. Includes 4.5:1 text contrast, 44×44px minimum touch target, reflow at 400% zoom.
- Level AAA — enhanced. Not required to be met in full for most pages.
ARIA communicates semantic information to assistive technologies when native HTML elements don't convey sufficient meaning. The first rule of ARIA: don't use ARIA if a native HTML element already has the semantics you need.
<!-- Wrong: ARIA redundant on native element -->
<button role="button" aria-label="Click me">Click me</button>
<!-- Wrong: div-button misses keyboard handling and focus -->
<div role="button" onClick={handleClick}>Click</div>
<!-- Correct: native element has everything -->
<button onClick={handleClick}>Click</button>
<!-- Correct: ARIA for custom slider (no native equivalent) -->
<div
role="slider" aria-valuemin={0} aria-valuemax={100}
aria-valuenow={value} aria-label="Volume"
tabIndex={0} onKeyDown={handleKeyDown}
/>
<!-- Live region — announce dynamic content to screen readers -->
<div aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
Valid ARIA uses: aria-expanded on accordions, aria-haspopup on dropdowns, aria-selected on tabs, aria-describedby linking errors to inputs, aria-live for toast notifications.
Principles: every interactive element reachable by Tab. Inside composite widgets (toolbar, menu, listbox) only one item in the tab order at a time — internal navigation uses arrow keys (roving tabindex). Never remove focus styles without a replacement (:focus-visible shows focus only for keyboard).
// Roving tabindex — Tab enters the toolbar, arrows navigate within
function ToolBar({ items }) {
const [activeIdx, setActiveIdx] = useState(0);
const handleKeyDown = (e) => {
if (e.key === 'ArrowRight') setActiveIdx(i => Math.min(i + 1, items.length - 1));
if (e.key === 'ArrowLeft') setActiveIdx(i => Math.max(i - 1, 0));
if (e.key === 'Home') setActiveIdx(0);
if (e.key === 'End') setActiveIdx(items.length - 1);
};
return (
<div role="toolbar">
{items.map((item, i) => (
<button
key={item.id}
tabIndex={i === activeIdx ? 0 : -1} // only active in tab order
onKeyDown={handleKeyDown}
onClick={item.action}
ref={i === activeIdx ? el => el?.focus() : null}
>
{item.label}
</button>
))}
</div>
);
}
In traditional multi-page apps, page navigation moves focus to the document top. SPAs don't reload the page — focus stays wherever it was, and screen reader users miss the new content.
Solutions:
- Route announcement — after navigation, announce the new page title via an ARIA live region. Next.js App Router does this automatically.
- Move focus to main content — programmatically focus the
<main>or<h1>after route change. - Skip navigation link — visually hidden link, first in the page, visible on focus, jumps past navigation to main content.
<!-- Skip link — first element in body -->
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:p-4 focus:bg-white"
>
Skip to main content
</a>
<main id="main-content" tabIndex={-1}>{children}</main>
// Focus main after route change
const mainRef = useRef(null);
const pathname = usePathname();
useEffect(() => { mainRef.current?.focus(); }, [pathname]);
<div>
<label htmlFor="email">
Email address <span aria-hidden="true">*</span>
<span className="sr-only">(required)</span>
</label>
<input
id="email"
type="email"
autoComplete="email"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
{...register('email')}
/>
<span id="email-hint" className="text-sm">We'll never share your email.</span>
{errors.email && (
<span id="email-error" role="alert" className="text-red-500">
{errors.email.message}
</span>
)}
</div>
Key requirements:
- Every input has a visible
<label>associated viahtmlFor/id— not placeholder text (disappears on input). - Error messages linked via
aria-describedby— announced by screen readers when focusing the field. aria-invalid="true"signals an invalid field.role="alert"on error messages announces them immediately when they appear.- Required fields use
aria-requiredand a visible cue (not colour alone).
prefers-reduced-motion and how do you respect it?Some users experience vestibular disorders, epilepsy, or motion sickness triggered by animation. Setting "Reduce Motion" in their OS exposes prefers-reduced-motion: reduce via a media query. WCAG 2.3.3 (AAA) recommends honouring this preference.
/* CSS — disable motion globally for affected users */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Better — design a reduced alternative, not just removal */
.card { transition: transform 200ms ease; }
@media (prefers-reduced-motion: reduce) {
.card { transition: opacity 200ms ease; } /* fade instead of slide */
}
// React — query via hook
function useReducedMotion() {
const [prefers, setPrefers] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefers(mq.matches);
mq.addEventListener('change', e => setPrefers(e.matches));
}, []);
return prefers;
}
// Framer Motion — built-in support
import { useReducedMotion } from 'framer-motion';
const shouldReduce = useReducedMotion();
const animation = shouldReduce ? {} : { y: [-20, 0], opacity: [0, 1] };
Automated tools (axe DevTools, Lighthouse, Storybook a11y addon, @axe-core/playwright) catch ~30–40% of WCAG issues — the objective, rule-based ones: missing alt text, contrast violations, missing form labels, invalid ARIA attributes.
// CI — run axe on every PR with Playwright
import { checkA11y } from 'axe-playwright';
test('homepage passes WCAG AA', async ({ page }) => {
await page.goto('/');
await checkA11y(page, undefined, {
runOnly: { type: 'tag', values: ['wcag2aa'] },
});
});
Automated tools cannot detect:
- Whether alt text is meaningful (they check it exists, not if it describes the image).
- Keyboard trap edge cases in custom widgets.
- Focus management correctness in modals and SPAs.
- Whether content is understandable (reading level, logical order).
- Whether errors are perceivable and actionable.
- Screen reader announcement quality (correct ARIA live region behaviour).
Manual testing checklist: navigate entirely by keyboard (Tab, arrow keys, Enter, Escape), test with VoiceOver (macOS/iOS) and NVDA (Windows), verify focus visible on every interactive element, test with 200% and 400% zoom, test with Windows High Contrast mode.