This guide covers the most frequently asked React interview questions in 2026 — from core concepts and hooks to advanced patterns, performance optimisation, Next.js, TypeScript, and testing strategies.
React is a JavaScript library for building user interfaces through a component-based model. It solves the problem of keeping the DOM in sync with application state.
JSX (JavaScript XML) is a syntax extension that lets you write HTML-like markup inside JavaScript. Babel compiles it to React.createElement() calls.
// JSX:
const element = <h1 className="title">Hello {name}</h1>;
// Compiled (React 17+ automatic JSX transform — no React import needed):
import { jsx as _jsx } from 'react/jsx-runtime';
const element = _jsx("h1", { className: "title", children: `Hello ${name}` });
// Key JSX rules:
// - Must return a single root element (or Fragment)
// - class → className, for → htmlFor (reserved JS keywords)
// - Self-close all tags: <img />, <br />, <Component />
// - Expressions in {}, not statements
// - Comments: {/* comment */}
// Controlled:
const [name, setName] = useState('');
<input value={name} onChange={e => setName(e.target.value)} />
// Uncontrolled:
const nameRef = useRef(null);
<input ref={nameRef} defaultValue="John" />
// Access: nameRef.current.value on submit
The Virtual DOM is a JavaScript object tree that mirrors the real DOM. On each render, React builds a new virtual DOM tree and diffs it against the previous one using its reconciliation algorithm (Fiber).
Diffing heuristics:
key prop to match old and new children. Without keys, React diffs by position (may produce wrong mutations).// Without key: React sees "3 <li>s" each time, re-renders all
// With key: React sees which item was added/removed/moved
{items.map(item => (
<li key={item.id}>{item.name}</li> // key should be stable unique ID
))}
React Fiber (React 16+) makes reconciliation interruptible — it can pause work, prioritise urgent updates (user input), and resume background work. This enables Concurrent Mode features like useTransition and Suspense.
key prop?EasyKeys help React identify which items in a list have changed, been added, or removed. React uses keys to match virtual DOM elements between renders.
// WRONG: using index as key — breaks reordering and deletion
{items.map((item, i) => <Item key={i} {...item} />)}
// RIGHT: use a stable, unique ID
{items.map(item => <Item key={item.id} {...item} />)}
// Keys must be unique among siblings (not globally unique)
// Keys are not passed as props — use a separate prop if the child needs the ID
When index is OK: static lists that never reorder or filter (e.g. a fixed list of navigation tabs). For any dynamic list, use a real ID.
Trick: you can use key to force a component to fully remount when a value changes — useful for resetting all internal state:
<UserProfile key={userId} userId={userId} />
// Changing userId forces complete remount, resetting all hooks
Props (properties) are the mechanism for passing data from parent to child components. They are read-only — a component must never modify its own props.
// TypeScript (preferred in 2026 — compile-time type safety):
interface ButtonProps {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary'; // optional with union type
disabled?: boolean;
}
const Button = ({ label, onClick, variant = 'primary', disabled = false }: ButtonProps) => (
<button className={`btn-${variant}`} onClick={onClick} disabled={disabled}>
{label}
</button>
);
// PropTypes (JS only — runtime validation, less common now):
Button.propTypes = {
label: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
variant: PropTypes.oneOf(['primary', 'secondary']),
};
Prop drilling is passing props through many intermediate components that don't use them — only pass them down to a deeply nested child.
// Problem: intermediary B and C don't use theme, just pass it
<A theme="dark">
<B theme="dark"> // B doesn't care
<C theme="dark"> // C doesn't care
<D theme="dark" /> // D actually needs it
</C>
</B>
</A>
// Solution 1: Context (for genuinely global/shared state)
const ThemeContext = React.createContext('light');
// Wrap: <ThemeContext.Provider value="dark">...</ThemeContext.Provider>
// Consume: const theme = useContext(ThemeContext);
// Solution 2: Component composition (often overlooked, very powerful)
// Pass the component that needs the data instead of the data
<Layout sidebar={<Sidebar theme={theme} />} />
// Layout doesn't know about theme at all
// Solution 3: State management library (Zustand, Redux) for complex cases
Fragments let you return multiple elements without adding an extra DOM node.
// Without Fragment — adds unnecessary div to DOM:
return (
<div>
<td>Cell 1</td>
<td>Cell 2</td>
</div> // invalid: div inside tr!
);
// With Fragment:
return (
<React.Fragment>
<td>Cell 1</td>
<td>Cell 2</td>
</React.Fragment>
);
// Shorthand (most common):
return (
<>
<td>Cell 1</td>
<td>Cell 2</td>
</>
);
// Note: shorthand <></> cannot have key prop.
// Use <React.Fragment key={id}> when rendering lists of fragments.
Functional components with hooks map to the same lifecycle phases as class components:
function MyComponent({ id }) {
const [data, setData] = useState(null);
// Mount + update: runs after every render by default
useEffect(() => {
console.log('Rendered');
});
// Mount only (empty deps array):
useEffect(() => {
console.log('Mounted — equivalent to componentDidMount');
return () => {
console.log('Unmounted — equivalent to componentWillUnmount');
};
}, []);
// Update when id changes (equivalent to componentDidUpdate for id):
useEffect(() => {
fetchData(id).then(setData);
return () => cancelFetch(); // cleanup before re-running or unmount
}, [id]);
// Render phase: the function body itself
return <div>{data}</div>;
}
Execution order: render → paint DOM → run effects. Effects run after paint so they don't block the browser. Use useLayoutEffect if you need to run synchronously after DOM mutation but before paint (rare — DOM measurements).
React.Component, use this.state and lifecycle methods. Still supported but no new features added.// Class (legacy):
class Counter extends React.Component {
state = { count: 0 };
increment = () => this.setState({ count: this.state.count + 1 });
render() {
return <button onClick={this.increment}>{this.state.count}</button>;
}
}
// Functional (modern — preferred):
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
use). Not regular JS functions, class methods, or event handlers.// WRONG: conditional hook call
if (condition) {
const [value, setValue] = useState(0); // breaks hook ordering!
}
// RIGHT: hook at top, condition inside
const [value, setValue] = useState(0);
useEffect(() => {
if (condition) { /* logic */ }
}, [condition]);
The eslint-plugin-react-hooks enforces these rules automatically. Always install it in your project.
useState — batching, functional updates, lazy initialisation.Mediumconst [count, setCount] = useState(0);
// Functional update: always use when next state depends on previous
setCount(prev => prev + 1); // safe
setCount(count + 1); // stale closure risk in async code
// Lazy initialisation (expensive computation only on mount):
const [data, setData] = useState(() => parseHeavyData(rawInput));
// The function is called once, not on every render
// Batching (React 18+): multiple setState calls in one event are batched
// into a single re-render automatically — even inside setTimeout, Promises
function handleClick() {
setCount(c => c + 1); // React 18: batched together
setName('Alice'); // single re-render for both
}
// Force immediate update (rare — avoid): use flushSync
import { flushSync } from 'react-dom';
flushSync(() => setCount(c => c + 1));
// DOM is updated before this line
useEffect — cleanup, dependency array pitfalls, strict mode double invoke.MediumuseEffect(() => {
const controller = new AbortController();
async function fetchUser() {
try {
const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
const data = await res.json();
setUser(data);
} catch (e) {
if (e.name !== 'AbortError') setError(e);
}
}
fetchUser();
return () => controller.abort(); // cleanup: cancel fetch on unmount or re-run
}, [userId]); // runs when userId changes
Dependency array pitfalls:
useMemo, functions with useCallback; or move them inside the effectStrict Mode double invoke (React 18 dev): React mounts → unmounts → remounts every component to help catch missing cleanup. Your effect runs twice in dev. This is intentional — fix your cleanup, don't disable Strict Mode.
useRef and when do you use it instead of state?MediumuseRef returns a mutable object ({ current: value }) that persists across renders but changing it does NOT trigger a re-render.
// Use case 1: DOM element reference
const inputRef = useRef(null);
<input ref={inputRef} />
inputRef.current.focus(); // programmatic focus
// Use case 2: store value without causing re-render
const timerRef = useRef(null);
timerRef.current = setInterval(() => {}, 1000);
// On cleanup:
clearInterval(timerRef.current);
// Use case 3: access latest state in a closure (avoid stale closure)
const latestCount = useRef(count);
useEffect(() => { latestCount.current = count; }); // always current
const handleTimeout = useCallback(() => {
console.log(latestCount.current); // always latest value
}, []); // no stale closure
ref vs state: use state when the value should trigger UI updates; use ref when it's internal bookkeeping (timer IDs, previous values, DOM nodes) that doesn't affect rendering.
useContext and when should you avoid it?Medium// Create context with a sensible default:
const ThemeContext = createContext<'light' | 'dark'>('light');
// Provider (wraps the subtree that needs the value):
function App() {
const [theme, setTheme] = useState<'light' | 'dark'>('dark');
return (
<ThemeContext.Provider value={theme}>
<Layout />
</ThemeContext.Provider>
);
}
// Consumer (any descendant):
function Button() {
const theme = useContext(ThemeContext);
return <button className={theme}>Click</button>;
}
When to avoid:
useReducer and when do you choose it over useState?Mediumtype Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset'; payload: number };
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'increment': return state + 1;
case 'decrement': return state - 1;
case 'reset': return action.payload;
default: throw new Error(`Unknown action: ${action.type}`);
}
}
const [count, dispatch] = useReducer(reducer, 0);
dispatch({ type: 'increment' });
dispatch({ type: 'reset', payload: 10 });
Choose useReducer when:
useMemo and useCallback — when do they help vs hurt?Medium// useMemo: memoize an expensive computed value
const sortedList = useMemo(
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
[items] // recompute only when items changes
);
// useCallback: memoize a function reference
const handleDelete = useCallback((id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []); // stable reference — doesn't change between renders
When they help:
useMemo: genuinely expensive computation (sorting 10k items, parsing CSV, heavy math)useCallback: function passed to a memoized child (React.memo) or as a useEffect dependencyWhen they hurt (premature optimisation):
Custom hooks are plain JavaScript functions that start with use and can call other hooks. They extract reusable stateful logic from components.
// Custom hook: useDebounce
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Usage:
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) search(debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
Naming convention: always start with use — React lint rules use this prefix to identify hooks. Popular custom hooks to know: useLocalStorage, useFetch, useIntersectionObserver, usePrevious, useEventListener.
useTransition and useDeferredValue?MediumReact 18 Concurrent Mode hooks for keeping the UI responsive during expensive updates.
// useTransition: mark a state update as non-urgent
const [isPending, startTransition] = useTransition();
function handleSearch(e) {
const value = e.target.value;
setInputValue(value); // urgent: update input immediately
startTransition(() => {
setSearchResults(filterItems(value)); // non-urgent: can be interrupted
});
}
// isPending = true while the transition is processing → show spinner
// useDeferredValue: defer a value until urgent updates settle
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
// deferredQuery lags behind query during fast typing
// avoids expensive re-render on every keystroke
const results = useMemo(
() => filterItems(deferredQuery),
[deferredQuery]
);
return <List items={results} />;
}
Difference: useTransition wraps the state setter (you control what's deferred). useDeferredValue wraps the value (useful when you don't control the setter — e.g. prop from parent).
useId, useImperativeHandle, and useInsertionEffect?Medium// useId: generate stable unique IDs for accessibility (SSR-safe)
function FormField({ label }) {
const id = useId();
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}
// Generates ":r0:", ":r1:" etc. — stable between server and client renders
// useImperativeHandle: expose imperative methods from a component to parent
const FancyInput = forwardRef((props, ref) => {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
shake: () => inputRef.current.animate([...], { duration: 300 }),
}));
return <input ref={inputRef} />;
});
// Parent: inputRef.current.shake()
// useInsertionEffect: CSS-in-JS libraries only
// Runs before any DOM mutations — lets libs inject styles before layout
// Suspense: show fallback while child is "suspended" (not ready)
<Suspense fallback={<Spinner />}>
<UserProfile userId={userId} />
</Suspense>
// A component suspends by throwing a Promise.
// React catches the thrown Promise, shows fallback,
// then re-renders when the Promise resolves.
// With React Query (v5+):
function UserProfile({ userId }) {
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// data is always defined here — no loading/error state needed
return <div>{data.name}</div>;
}
// With Next.js App Router:
// async Server Components suspend automatically
async function UserPage({ params }) {
const user = await fetchUser(params.id); // suspends naturally
return <UserProfile user={user} />;
}
Pair with ErrorBoundary to catch rejected Promises. React 18 also enables streaming SSR with Suspense — the server streams HTML as each Suspense boundary resolves.
Error Boundaries catch JavaScript errors in their child component tree during rendering, lifecycle methods, and constructors. They must be class components (no hook equivalent for componentDidCatch yet).
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
logErrorToService(error, info.componentStack);
}
render() {
if (this.state.hasError) {
return this.props.fallback || <h2>Something went wrong.</h2>;
}
return this.props.children;
}
}
// Usage:
<ErrorBoundary fallback={<ErrorPage />}>
<Dashboard />
</ErrorBoundary>
// Use react-error-boundary library for a hooks-friendly API:
import { ErrorBoundary } from 'react-error-boundary';
<ErrorBoundary FallbackComponent={ErrorPage} onError={logError}>
import { create } from 'zustand';
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: () => number;
}
const useCart = create<CartStore>((set, get) => ({
items: [],
addItem: (item) => set(state => ({ items: [...state.items, item] })),
removeItem: (id) => set(state => ({ items: state.items.filter(i => i.id !== id) })),
total: () => get().items.reduce((sum, i) => sum + i.price, 0),
}));
// In component — only re-renders when items changes:
const items = useCart(state => state.items);
const addItem = useCart(state => state.addItem);
Why Zustand over Redux:
Redux Toolkit is still excellent for large teams needing strict conventions, time-travel debugging, and RTK Query for server state.
TanStack Query (React Query) manages server state — async data from APIs. It handles caching, background refetching, stale-while-revalidate, pagination, mutations, and optimistic updates.
// Without React Query: manual loading/error/cache state in every component
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => { fetch('/api/users').then(...) // repeated everywhere
// With React Query:
const { data, isLoading, error } = useQuery({
queryKey: ['users'], // cache key
queryFn: () => fetch('/api/users').then(r => r.json()),
staleTime: 60_000, // consider fresh for 1 min
gcTime: 5 * 60_000, // keep in cache for 5 min after unmount
});
// Mutation with optimistic update:
const mutation = useMutation({
mutationFn: (newUser) => fetch('/api/users', { method: 'POST', body: JSON.stringify(newUser) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
Separates server state (async, remote, eventually consistent) from client state (local, synchronous). Most apps only need React Query + Zustand — no Redux needed.
// Classic Redux: verbose (action types, action creators, switch reducer)
const INCREMENT = 'counter/increment';
const increment = () => ({ type: INCREMENT });
function counterReducer(state = 0, action) {
switch (action.type) {
case INCREMENT: return state + 1;
default: return state;
}
}
// Redux Toolkit (RTK): concise
import { createSlice, configureStore } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => { state.value += 1; }, // Immer under hood — safe mutation
incrementBy: (state, action) => { state.value += action.payload; },
},
});
export const { increment, incrementBy } = counterSlice.actions;
const store = configureStore({ reducer: { counter: counterSlice.reducer } });
RTK includes Immer (safe mutations), RTK Query (data fetching + caching), and Redux DevTools setup. If you use Redux, always use RTK — classic Redux is considered legacy.
import { atom, useAtom, useAtomValue } from 'jotai';
// Atoms: tiny independent pieces of state
const countAtom = atom(0);
const nameAtom = atom('Alice');
// Derived atom (computed — like useMemo but for global state):
const doubledAtom = atom(get => get(countAtom) * 2);
// Writable derived atom:
const uppercaseNameAtom = atom(
get => get(nameAtom).toUpperCase(),
(get, set, newValue) => set(nameAtom, newValue.toLowerCase())
);
// In component — only re-renders when countAtom changes:
function Counter() {
const [count, setCount] = useAtom(countAtom);
const doubled = useAtomValue(doubledAtom); // read-only
return <button onClick={() => setCount(c => c + 1)}>{count} x2={doubled}</button>;
}
Jotai is excellent for fine-grained reactivity. Components only re-render when their specific atoms change. No Provider needed at the component level (uses a default store). Great for atomic UI state (modal open/close, selected item, filters).
useState) — used by only one component or its direct children. Form input values, toggle state, accordion open/closed, hover state. Always prefer local state first.// Without Immer: deeply nested immutable update is verbose and error-prone
setState(prev => ({
...prev,
users: prev.users.map(u =>
u.id === targetId
? { ...u, address: { ...u.address, city: 'Mumbai' } }
: u
)
}));
// With Immer: write mutating code, Immer produces immutable result
import { produce } from 'immer';
setState(prev => produce(prev, draft => {
const user = draft.users.find(u => u.id === targetId);
if (user) user.address.city = 'Mumbai'; // "mutate" the draft
}));
// useImmer hook:
import { useImmer } from 'use-immer';
const [state, updateState] = useImmer(initialState);
updateState(draft => { draft.users[0].city = 'Mumbai'; });
Immer uses JavaScript Proxy to track mutations on a draft object, then produces an immutable updated state. RTK uses Immer internally in createSlice reducers.
// Custom hook: useLocalStorage
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch { return initialValue; }
});
const setStoredValue = (newValue: T) => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
};
return [value, setStoredValue] as const;
}
// Zustand with persist middleware:
import { persist } from 'zustand/middleware';
const useStore = create(persist(
(set) => ({ theme: 'dark', setTheme: (t) => set({ theme: t }) }),
{ name: 'app-settings', storage: createJSONStorage(() => localStorage) }
));
typeof window !== 'undefined'.// Lightweight alternative to Redux for medium-complexity apps
const StoreContext = createContext(null);
function storeReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': return { ...state, items: [...state.items, action.item] };
default: return state;
}
}
function StoreProvider({ children }) {
const [state, dispatch] = useReducer(storeReducer, { items: [] });
// Split context to avoid re-rendering dispatch consumers on state change:
return (
<StoreContext.Provider value={{ state, dispatch }}>
{children}
</StoreContext.Provider>
);
}
// Optimisation: split into StateContext + DispatchContext
// Components that only dispatch don't re-render when state changes
React.memo and when does it help?Medium// React.memo: skip re-render if props haven't changed (shallow equal)
const ExpensiveList = React.memo(function ExpensiveList({ items, onSelect }) {
return items.map(item => <Item key={item.id} item={item} onSelect={onSelect} />);
});
// IMPORTANT: works only if props are stable references!
// Parent re-renders → new function reference each time:
<ExpensiveList
items={items}
onSelect={(id) => console.log(id)} // new function every render → memo useless!
/>
// Fix: stabilise the function with useCallback
const handleSelect = useCallback((id) => console.log(id), []);
<ExpensiveList items={items} onSelect={handleSelect} />
// Custom comparison:
const ExpensiveList = React.memo(List, (prev, next) =>
prev.items.length === next.items.length // custom equality
);
Profile before adding React.memo. It adds a comparison cost on every render — only beneficial when the comparison is cheaper than the re-render it prevents.
// Without code splitting: entire app JS in one bundle → slow initial load
// With React.lazy: each lazy-loaded chunk downloaded only when needed
// Route-level splitting (most impactful):
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-level splitting (heavy components: chart libraries, editors):
const RichEditor = lazy(() => import('./components/RichEditor'));
// Shown only when user clicks "edit" — why load 200KB Quill on every page?
// Preload on hover (UX improvement):
const preloadDashboard = () => import('./pages/Dashboard');
<Link to="/dashboard" onMouseEnter={preloadDashboard}>Dashboard</Link>
Virtualisation renders only the visible rows/items in a long list — not all 10,000 items at once. Unmounted items are replaced by spacer divs that maintain scroll position.
// TanStack Virtual (replaces react-window/react-virtualized):
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }) {
const parentRef = useRef(null);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // estimated row height
});
return (
<div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
<div style={{ height: rowVirtualizer.getTotalSize() }}>
{rowVirtualizer.getVirtualItems().map(virtualRow => (
<div key={virtualRow.key}
style={{ position:'absolute', top: virtualRow.start, height: virtualRow.size }}>
{items[virtualRow.index].name}
</div>
))}
</div>
</div>
);
}
Use for: lists >500 items, data grids, infinite scroll feeds. Renders only ~20–30 DOM nodes regardless of list size.
Tools:
// why-did-you-render setup:
import whyDidYouRender from '@welldone-software/why-did-you-render';
whyDidYouRender(React, { trackAllPureComponents: true });
// Common causes of re-renders:
// 1. New object/array reference in JSX: <Child data={{}} /> → new ref every render
// 2. New function in JSX: <Child onClick={() => {}} /> → wrap in useCallback
// 3. Context value changing: split large contexts or use useMemo on value
// 4. State too high in tree: move state down closer to where it's used
React Server Components (RSC) run exclusively on the server — their code is never sent to the browser. They can directly access databases, file systems, and internal APIs without exposing credentials to the client.
// app/users/page.tsx — Server Component (default in Next.js App Router)
// No 'use client' directive = server component
async function UsersPage() {
// Direct DB access — no API layer needed, no credentials exposed to client
const users = await db.query('SELECT * FROM users LIMIT 20');
return (
<div>
{users.map(u => <UserCard key={u.id} user={u} />)}
<AddUserButton /> {/* client component for interactivity */}
</div>
);
}
// 'use client' — runs on client, can use hooks and event handlers
'use client';
function AddUserButton() {
const [open, setOpen] = useState(false);
return <button onClick={() => setOpen(true)}>Add User</button>;
}
Benefits: zero JS sent for server-only code, no waterfall API calls (fetch in parallel on server), data co-located with components, smaller client bundle. RSC is the foundation of Next.js App Router.
// Next.js Image component: automatic optimisation
import Image from 'next/image';
// Automatically:
// - Converts to WebP/AVIF (30-50% smaller)
// - Lazy loads images below the fold
// - Prevents layout shift (requires width + height or fill)
// - Serves correct size for the device (srcset)
// - Caches optimised images
<Image
src="/hero.jpg"
alt="Hero banner"
width={1200}
height={600}
priority // preload above-the-fold images (remove lazy loading)
placeholder="blur"
blurDataURL={blurDataUrl} // tiny LQIP shown while loading
/>
// Core Web Vitals affected by images:
// LCP (Largest Contentful Paint): hero image — use priority + preload
// CLS (Cumulative Layout Shift): always specify dimensions to reserve space
useTransition for non-urgent updates.// Measure in Next.js (built-in):
// app/layout.tsx
export function reportWebVitals(metric) {
// Send to your analytics:
analytics.track(metric.name, { value: metric.value, id: metric.id });
}
// Or use web-vitals library:
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
The React Compiler (previously "React Forget") is a Babel/compiler plugin that automatically memoizes React components and hooks — eliminating the need for manual useMemo, useCallback, and React.memo.
Problem it solves: Developers either forget to memoize (causing unnecessary re-renders) or over-memoize (adding complexity/overhead everywhere). Both are bugs.
// Before React Compiler: you write this
const handleClick = useCallback(() => doSomething(id), [id]);
const memoedValue = useMemo(() => compute(data), [data]);
const Child = React.memo(ChildComponent);
// After React Compiler: write this, compiler adds memoization automatically
const handleClick = () => doSomething(id);
const memoedValue = compute(data);
// Child is automatically memoized if the compiler can prove it's safe
Released in React 19 (2025). Requires your code to follow React rules (no mutation of props, etc.). Gradually adoptable per file/component. Meta uses it in production on Instagram. Enabled in Next.js 15 config.
// Next.js App Router (React Server Components):
// SSR: fetch inside server component (default — no cache)
const data = await fetch('/api/data', { cache: 'no-store' });
// SSG: fetch at build time (static)
const data = await fetch('/api/data', { cache: 'force-cache' });
// ISR: revalidate every 60 seconds
const data = await fetch('/api/data', { next: { revalidate: 60 } });
// Or: export const revalidate = 60; at the segment level
// Server Action: async function marked 'use server' — runs on server
// called directly from client components or forms (no API route needed)
// app/actions.ts
'use server';
export async function createUser(formData: FormData) {
const name = formData.get('name') as string;
await db.users.create({ data: { name } });
revalidatePath('/users'); // revalidate cached page
}
// Client component: call server action directly
'use client';
import { createUser } from './actions';
function AddUserForm() {
return (
<form action={createUser}> {/* Next.js wires the POST automatically */}
<input name="name" />
<button type="submit">Add</button>
</form>
);
}
// Or call imperatively:
const handleClick = async () => {
await createUser(formData); // RPC-like call to server
};
Server Actions replace API routes for mutations. They work without JavaScript (progressive enhancement), support optimistic updates via useOptimistic, and are automatically CSRF-protected.
| Feature | Pages Router | App Router |
|---|---|---|
| Default component type | Client | Server |
| Data fetching | getServerSideProps / getStaticProps | async component / fetch() |
| Layouts | _app.tsx (global only) | Nested layout.tsx files |
| Loading state | Manual | loading.tsx (auto Suspense) |
| Error handling | Manual | error.tsx (auto Error Boundary) |
| Streaming SSR | No | Yes (Suspense-based) |
// App Router file conventions:
app/
layout.tsx // shared layout (persistent across navigation)
loading.tsx // Suspense fallback
error.tsx // Error boundary
not-found.tsx // 404
page.tsx // route segment
users/
[id]/
page.tsx // /users/123
// 1. Type props with interface (extendable) or type (unions/intersections)
interface ButtonProps {
children: React.ReactNode; // any renderable content
onClick?: () => void;
variant: 'primary' | 'danger';
}
// 2. Generic components
function Select<T extends { id: string; label: string }>({
options,
onSelect,
}: {
options: T[];
onSelect: (item: T) => void;
}) { /* ... */ }
// 3. Type events properly
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); };
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {};
// 4. useRef with null (DOM ref):
const ref = useRef<HTMLInputElement>(null);
ref.current?.focus(); // optional chain — current is null before mount
// 5. Discriminated unions for component variants:
type AlertProps =
| { type: 'success'; message: string }
| { type: 'error'; message: string; onRetry: () => void };
import { render, screen, userEvent } from '@testing-library/react';
import { vi } from 'vitest';
// Guiding principle: "Test the way users use your app, not implementation details"
test('submitting a login form calls onLogin with credentials', async () => {
const user = userEvent.setup();
const mockLogin = vi.fn();
render(<LoginForm onLogin={mockLogin} />);
// Query by accessible role/label (not by className or test-id)
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(mockLogin).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
// Query priority (use in this order):
// getByRole → getByLabelText → getByPlaceholderText → getByText
// Avoid: getByTestId (last resort), getByClassName (implementation detail)
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
// MSW (Mock Service Worker): intercept real fetch calls in tests
const server = setupServer(
http.get('/api/users', () => {
return HttpResponse.json([{ id: 1, name: 'Alice' }]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('displays users after loading', async () => {
render(<UserList />);
// Loading state:
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for async to resolve:
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
// Test error state:
test('shows error on API failure', async () => {
server.use(http.get('/api/users', () => new HttpResponse(null, { status: 500 })));
render(<UserList />);
await screen.findByText(/error/i); // findBy = waitFor + getBy
});
| Aspect | Jest | Vitest |
|---|---|---|
| Bundler | Babel (separate transform) | Vite-native (same config) |
| Speed | Slower (cold start) | 3-10x faster (ESM + HMR) |
| TypeScript | Needs ts-jest or babel | Native, no config needed |
| API compatibility | — | Jest-compatible (drop-in) |
// vitest.config.ts:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/setupTests.ts'],
globals: true, // no need to import describe/it/expect
},
});
// setupTests.ts:
import '@testing-library/jest-dom';
Vitest is the default for new Vite-based React projects. Jest remains common in CRA or older codebases. API is nearly identical — migration is straightforward.
Storybook is an isolated component development environment. You build and document components in isolation, without needing to run the full app.
// Button.stories.tsx:
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
component: Button,
tags: ['autodocs'], // auto-generate docs from JSDoc + prop types
};
export default meta;
export const Primary: StoryObj<typeof Button> = {
args: { label: 'Click me', variant: 'primary' },
};
export const Disabled: StoryObj<typeof Button> = {
args: { label: 'Disabled', disabled: true },
};
// Each "story" = one component state = one visual test case
Benefits: design system documentation, visual regression testing (Chromatic), faster development (no app context needed), designer handoff. Storybook 8 (2024) improved performance and native Vite support.
A design system is a shared library of reusable UI components, tokens (colors, spacing, typography), and guidelines that ensure visual and functional consistency across products.
// tokens.ts: design tokens (source of truth)
export const tokens = {
colors: { primary: '#6366f1', danger: '#ef4444', text: '#1e293b' },
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
fontSizes: { sm: '0.875rem', base: '1rem', lg: '1.125rem' },
} as const;
// Component with variants (using class-variance-authority):
import { cva } from 'class-variance-authority';
const buttonVariants = cva('rounded-lg font-semibold transition-colors', {
variants: {
variant: {
primary: 'bg-indigo-500 text-white hover:bg-indigo-600',
ghost: 'bg-transparent text-indigo-500 hover:bg-indigo-50',
},
size: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
});
Popular approaches: Radix UI (unstyled, accessible primitives) + Tailwind CSS, shadcn/ui (copy-paste Radix + Tailwind), Chakra UI, MUI.
// 1. Semantic HTML first:
<button onClick={handleClick}>Save</button> // NOT <div onClick>
<nav>, <main>, <header>, <article> // landmark roles
// 2. ARIA when semantics aren't enough:
<div role="alert" aria-live="polite">{errorMessage}</div>
<button aria-label="Close dialog" aria-expanded={isOpen}><XIcon /></button>
// 3. Focus management for modals/dialogs:
// When modal opens → focus first interactive element
// When modal closes → return focus to trigger button
// Trap focus inside modal (use Radix Dialog — it handles this)
// 4. Color contrast: minimum 4.5:1 for normal text (WCAG AA)
// 5. Keyboard navigation: all interactive elements reachable by Tab,
// activated by Enter/Space
// Tooling:
// eslint-plugin-jsx-a11y: lint for common a11y issues in JSX
// axe DevTools: browser extension + @axe-core/react for test integration
// Radix UI: all primitives are WAI-ARIA compliant out of the box
use hook?Hard// React 19's new 'use' hook: read resources in render
// (unlike other hooks, can be called conditionally)
import { use, Suspense } from 'react';
// Use a Promise (suspends until resolved):
function UserName({ userPromise }) {
const user = use(userPromise); // suspends if not resolved yet
return <h1>{user.name}</h1>;
}
// Pass a promise from Server Component to Client Component:
// app/page.tsx (Server Component)
export default function Page() {
const userPromise = fetchUser(1); // starts fetch, doesn't await
return (
<Suspense fallback={<Spinner />}>
<UserName userPromise={userPromise} />
</Suspense>
);
}
// Use Context (conditionally!):
function Component({ show }) {
if (!show) return null;
const theme = use(ThemeContext); // valid — use() can be in conditionals
return <div className={theme}>...</div>;
}
The use hook is a step toward a more unified async model in React. It works with any Thenable (Promise-like), not just native Promises.
src/
app/ # Next.js App Router pages + layouts
features/ # Feature-based slices (preferred over type-based)
auth/
components/ # components used only by auth feature
hooks/ # useAuth, useSession
api/ # API calls for auth
store/ # auth Zustand slice
types.ts
dashboard/
products/
shared/ # truly shared across features
components/ # Button, Modal, Table, Input (design system)
hooks/ # useDebounce, useLocalStorage
utils/ # formatCurrency, validateEmail
types/ # common TypeScript types
api/ # API client, interceptors
Key architectural decisions:
@/features/auth) — avoid ../../.. hellindex.ts) for public API of each feature — other features import from the barrel