React
100 Questions & Answers with Code Examples
React keeps a lightweight in-memory representation of the DOM called the Virtual DOM. On every state change, React re-renders the component tree to a new Virtual DOM, diffs it against the previous snapshot (reconciliation), and applies only the minimal set of real DOM mutations needed. This batches multiple updates into one browser paint cycle instead of modifying the DOM synchronously on every change.
// React batches these — only ONE DOM update happens
function Counter() {
const [count, setCount] = React.useState(0);
function handleClick() {
setCount(c => c + 1); // queued
setCount(c => c + 1); // queued
setCount(c => c + 1); // queued
// React 18: all three are batched → single re-render with count+3
}
return <button onClick={handleClick}>Count: {count}</button>;
}JSX is syntactic sugar. Babel/SWC transforms each JSX element to a React.createElement(type, props, ...children) call (classic runtime) or an _jsx() import from the automatic runtime. The result is a plain JavaScript object — a React element descriptor — not actual DOM. This makes JSX a full JS expression: usable in variables, returned from functions, passed as props.
// What you write:
const el = <h1 className="title">Hello {name}</h1>;
// What Babel compiles to (automatic runtime):
import { jsx as _jsx } from 'react/jsx-runtime';
const el = _jsx('h1', { className: 'title', children: `Hello ${name}` });
// The resulting object (a React element):
// { type: 'h1', props: { className: 'title', children: 'Hello Alice' }, key: null }In React, data flows down the component tree via props; events bubble up via callback props. A child cannot directly mutate its parent’s state — it calls a function the parent passed down. This makes the data flow explicit and traceable: if state changes unexpectedly, you look at the single component that owns it, not every subscriber.
// Parent owns state; child gets read access (prop) and write access (callback)
function Parent() {
const [value, setValue] = React.useState('');
return (
<div>
<Child value={value} onChange={setValue} /> {/* data down, event up */}
<p>Current: {value}</p>
</div>
);
}
function Child({ value, onChange }) {
// Cannot set parent state directly — must call onChange
return <input value={value} onChange={e => onChange(e.target.value)} />;
}Before React 18, state updates inside async callbacks (setTimeout, fetch.then, native event listeners) caused one re-render per setState call. React 18 batches all state updates regardless of where they happen — event handlers, async code, startTransition. Opt out per-update with ReactDOM.flushSync.
// React 18: both setters batched → ONE re-render
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// Previously: 2 re-renders; now: 1
}, 1000);
// Opt out — force synchronous DOM flush (rare)
import { flushSync } from 'react-dom';
flushSync(() => setCount(c => c + 1)); // DOM updated immediately
flushSync(() => setFlag(f => !f)); // DOM updated immediatelyReact’s diffing algorithm compares old and new element trees level by level. If the element type changed (e.g., div → span), React tears down the old subtree and creates a fresh one. Same type: update props in-place. For lists, React uses the key prop to match elements across renders — keys that survive a re-render keep their DOM node and state.
// React matches by type + position by default
// Changing type forces unmount/mount
{showError ? <p>Error!</p> : <span>OK</span>}
// ^ p and span are different types — full DOM swap
// Key forces identity across position changes
{items.map(item => (
<Row key={item.id} data={item} /> // key=id: Row survives sort/reorder
))}A component must return a single root element. Wrapping siblings in a <div> pollutes the DOM and breaks CSS layouts (flexbox, grid) that depend on direct parent-child relationships. React.Fragment (or the shorthand <>...</>) groups children without emitting any DOM node.
// ❌ Adds an extra div — breaks table / dl / flex layouts
function Cols() { return <div><td>A</td><td>B</td></div>; }
// ✅ No DOM node emitted
function Cols() {
return (
<>
<td>A</td>
<td>B</td>
</>
);
}
// Named Fragment — needed when key prop is required (lists)
items.map(item => (
<React.Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.def}</dd>
</React.Fragment>
))In development, StrictMode double-invokes render functions, useState initializers, and useReducer reducers to expose side effects in “pure” code. It also intentionally mounts/unmounts/remounts components once to verify useEffect cleanup works correctly. None of this happens in production builds.
// main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
// If your useEffect fires twice in dev, it's StrictMode testing your cleanup:
useEffect(() => {
const sub = subscribe(channel); // setup
return () => sub.unsubscribe(); // ✅ cleanup required — StrictMode verifies this
}, [channel]);ReactDOM.render (legacy) uses the synchronous rendering model. createRoot enables the concurrent renderer — it can interrupt, pause, and resume renders, enabling Suspense, startTransition, and streaming SSR. Always use createRoot for new projects.
// Legacy — synchronous, blocks main thread
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));
// React 18 — concurrent renderer, opt-in to all new features
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App />);
// Hydration for SSR
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('root'), <App />);A reusable component should accept data and callbacks via props rather than hard-coding values, avoid importing context it doesn’t need, and express its interface clearly. Accept className and spread remaining props onto the root element so callers can style and extend it.
// ✅ Reusable — accepts data, delegates actions, passes through props
function Button({ variant = 'primary', loading, children, className, ...rest }) {
return (
<button
className={`btn btn-${variant} ${className ?? ''}`}
disabled={loading}
{...rest} // onClick, type, aria-*, data-* from caller
>
{loading ? <Spinner size="sm" /> : children}
</button>
);
}
// Usage
<Button variant="danger" loading={isSubmitting} onClick={handleDelete}>
Delete
</Button>Prop drilling means passing props through intermediate components that don’t use the data themselves — they just relay it deeper. It’s a problem when: intermediate components are bloated with irrelevant props, adding a new prop requires editing 4+ files, or unrelated sibling components need the same data. Solutions: Context API, state management, or component composition.
// ❌ Drilling — Layout and Sidebar don't use user, just pass it on
<App user={user}>
<Layout user={user}>
<Sidebar user={user}>
<Avatar user={user} /> {/* ← only this needs it */}
</Sidebar>
</Layout>
</App>
// ✅ Composition — pass Avatar directly, skip intermediate props
function App() {
const user = useCurrentUser();
return <Layout sidebar={<Sidebar avatar={<Avatar user={user} />} />} />;
}children is the built-in prop that receives everything between the opening and closing tags. It enables container components (cards, modals, layouts) to be content-agnostic — they provide structure and behavior while the caller provides the content. More flexible than forcing content via a named prop.
function Card({ title, children, footer }) {
return (
<div className="card">
{title && <div className="card-header">{title}</div>}
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
// Caller controls body content — Card doesn't care what it is
<Card title="Order Summary" footer={<Button>Pay Now</Button>}>
<OrderLineItems items={items} />
<PriceBreakdown total={total} />
</Card>PropTypes runs in the browser at runtime and logs warnings in the console for wrong prop types — it doesn’t prevent rendering and has zero effect in production builds. TypeScript checks at compile time, catches errors before the code ships, provides IDE autocomplete, and scales better. Use TypeScript for new projects; PropTypes is legacy.
// TypeScript — compile-time, IDE support, no runtime cost
interface ButtonProps {
variant?: 'primary' | 'danger' | 'ghost';
loading?: boolean;
onClick: () => void;
children: React.ReactNode;
}
function Button({ variant = 'primary', loading = false, onClick, children }: ButtonProps) {
return (
<button className={`btn-${variant}`} disabled={loading} onClick={onClick}>
{children}
</button>
);
}
// TS error: Argument of type '"success"' is not assignable to type '"primary" | "danger" | "ghost"'
<Button variant="success">Go</Button>When a key value changes, React treats the component as a completely different instance — it unmounts the old one (running cleanup effects) and mounts a fresh one. This is a controlled way to reset a component’s entire state without lifting state up or adding reset logic inside the component.
// Resetting a form when the selected user changes
// Without key, the form's internal state persists across user switches
function UserEditor({ userId }) {
return (
<EditForm
key={userId} {/* ← changing userId unmounts old form, mounts fresh one */}
userId={userId}
/>
);
}
// Resetting a video player when src changes
<VideoPlayer key={videoId} src={videoUrl} />In functional components, use destructuring defaults directly in the parameter list — this is the idiomatic modern approach. The old Component.defaultProps static property is deprecated in React 19. Destructuring defaults are evaluated at call time, support expressions, and are visible in type signatures.
// ✅ Idiomatic — destructuring defaults
function Pagination({
page = 1,
perPage = 20,
totalItems = 0,
onPageChange,
showSummary = true
}) {
const totalPages = Math.ceil(totalItems / perPage);
return (
<nav aria-label="Pagination">
{showSummary && <span>Page {page} of {totalPages}</span>}
<button disabled={page <= 1} onClick={() => onPageChange(page - 1)}>Prev</button>
<button disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}>Next</button>
</nav>
);
}React explicitly recommends composition over inheritance. Rather than extending a component class to share behavior, compose: pass components as props, use children, or extract shared logic into a custom hook. This avoids the deep inheritance hierarchies that make code hard to follow and refactor.
// ❌ Inheritance — don't do this in React
class SpecialButton extends Button { ... }
// ✅ Composition — wrap and extend via props
function IconButton({ icon, children, ...rest }) {
return (
<Button {...rest}>
<i className={`bi bi-${icon}`} /> {children}
</Button>
);
}
// ✅ Shared behavior via custom hook
function useFormField(initial) {
const [value, setValue] = React.useState(initial);
const reset = () => setValue(initial);
return { value, onChange: e => setValue(e.target.value), reset };
}React.memo wraps a component and shallowly compares props before re-rendering. If props haven’t changed (same references), it reuses the previous render output. Effective for expensive components that receive the same props during a parent re-render. Pass a custom comparison function as the second argument for deep or selective comparison.
// Expensive list item — only re-renders when item or onSelect actually changes
const ProductCard = React.memo(function ProductCard({ item, onSelect }) {
console.log('rendering', item.id);
return (
<div className="card" onClick={() => onSelect(item.id)}>
<img src={item.image} alt={item.name} />
<h3>{item.name}</h3>
<p>${item.price}</p>
</div>
);
});
// Custom comparator — ignore timestamp field
const MemoizedRow = React.memo(Row, (prev, next) =>
prev.id === next.id && prev.value === next.value
);When you call setState(newValue), newValue is captured from the current closure — which may be stale if the state has updated since the last render. The functional form setState(prev => newValue) always receives the latest state as prev, regardless of closure age. Always use the functional form when new state depends on old state.
// ❌ Stale closure — count captured at render time
function Counter() {
const [count, setCount] = React.useState(0);
function addThree() {
setCount(count + 1); // all three see same stale count
setCount(count + 1);
setCount(count + 1); // result: count+1, not count+3
}
}
// ✅ Functional update — always gets latest value
function Counter() {
const [count, setCount] = React.useState(0);
function addThree() {
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1); // result: count+3 ✓
}
}The dependency array tells React which values the effect cares about. No array: runs after every render. Empty array []: runs once after mount. Array with values: re-runs only when those values change (by reference comparison). Missing a dependency is the #1 source of stale closure bugs in React.
// Fetch when userId changes — rerun on every userId change
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => { if (!cancelled) setUser(data); });
return () => { cancelled = true; }; // cleanup prevents stale updates
}, [userId]); // ← userId in deps, so effect reruns when userId changes
// ❌ Missing dependency — stale closure
useEffect(() => {
setInterval(() => console.log(count), 1000); // count never updates
}, []); // should be [count] or use useRef patternuseRef returns a mutable object { current: value } that persists across renders. Mutating .current does NOT trigger a re-render — unlike useState. Use it for: DOM node references, timer IDs, previous values, and values that need to survive renders without causing them.
function SearchInput() {
const inputRef = React.useRef(null);
// DOM reference — focus the input on mount
React.useEffect(() => { inputRef.current?.focus(); }, []);
return <input ref={inputRef} type="search" />;
}
// Timer ID that doesn't cause re-renders
function Poller({ url }) {
const timerRef = React.useRef(null);
React.useEffect(() => {
timerRef.current = setInterval(() => fetch(url), 5000);
return () => clearInterval(timerRef.current); // cleanup
}, [url]);
}Every render creates a new function reference. A child wrapped in React.memo receives a “new” prop and re-renders even if the logic is identical. useCallback returns a memoized function that stays the same reference across renders — unless its dependencies change. Only add useCallback when the function is a prop to a memoized child or a useEffect dependency.
function Parent({ userId }) {
const [items, setItems] = React.useState([]);
// Without useCallback: new reference every render → MemoizedList always re-renders
// With useCallback: same reference unless userId changes
const handleDelete = React.useCallback((id) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []); // no deps — setItems is stable
return <MemoizedList items={items} onDelete={handleDelete} />;
}useMemo runs a computation only when its dependencies change, returning the cached result on other renders. Use it when a derived value is expensive to compute (sorting/filtering large arrays, heavy number crunching) and would otherwise run on every render. Don’t use it for trivial operations — memoization has its own overhead.
function ProductList({ products, searchTerm, sortBy }) {
// Recalculated only when products, searchTerm, or sortBy changes
const filtered = React.useMemo(() => {
return products
.filter(p => p.name.toLowerCase().includes(searchTerm.toLowerCase()))
.sort((a, b) => sortBy === 'price'
? a.price - b.price
: a.name.localeCompare(b.name));
}, [products, searchTerm, sortBy]);
return (
<ul>{filtered.map(p => <ProductCard key={p.id} product={p} />)}</ul>
);
}useReducer is preferable when: state has multiple fields that change together, next state depends on previous in non-trivial ways, or you want to centralize state logic in a testable pure function (the reducer) rather than scattering it across event handlers.
const initialState = { status: 'idle', data: null, error: null };
function fetchReducer(state, action) {
switch (action.type) {
case 'FETCH_START': return { status: 'loading', data: null, error: null };
case 'FETCH_SUCCESS': return { status: 'success', data: action.payload, error: null };
case 'FETCH_ERROR': return { status: 'error', data: null, error: action.error };
default: return state;
}
}
function UserProfile({ id }) {
const [state, dispatch] = React.useReducer(fetchReducer, initialState);
React.useEffect(() => {
dispatch({ type: 'FETCH_START' });
fetchUser(id)
.then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
.catch(err => dispatch({ type: 'FETCH_ERROR', error: err.message }));
}, [id]);
if (state.status === 'loading') return <Spinner />;
if (state.status === 'error') return <ErrorMessage msg={state.error} />;
return <ProfileCard user={state.data} />;
}A custom hook is a regular function whose name starts with use and that calls other hooks. It encapsulates state + side effects so multiple components can share the same logic without duplicating code. The hook’s state is independent per component — no shared singleton.
// Reusable async data fetching hook
function useFetch(url) {
const [state, dispatch] = React.useReducer(fetchReducer, initialState);
React.useEffect(() => {
if (!url) return;
let cancelled = false;
dispatch({ type: 'FETCH_START' });
fetch(url)
.then(r => r.json())
.then(data => { if (!cancelled) dispatch({ type: 'FETCH_SUCCESS', payload: data }); })
.catch(err => { if (!cancelled) dispatch({ type: 'FETCH_ERROR', error: err.message }); });
return () => { cancelled = true; };
}, [url]);
return state;
}
// Each component gets its own state instance
function Users() { const { data, status } = useFetch('/api/users'); ... }
function Orders() { const { data, status } = useFetch('/api/orders'); ... }React tracks hook calls by their call order, not by name. Rules: (1) Only call hooks at the top level — never inside conditionals, loops, or nested functions. (2) Only call hooks from React function components or custom hooks. Violating these rules breaks the call order and causes state to be assigned to the wrong hook slot.
// ❌ Conditional hook — call order changes between renders
function Profile({ isLoggedIn }) {
if (isLoggedIn) {
const user = useUser(); // sometimes hook 1, sometimes skipped
}
const theme = useTheme(); // call order depends on isLoggedIn!
}
// ✅ Hooks at top level, condition inside
function Profile({ isLoggedIn }) {
const user = useUser(); // always hook 1
const theme = useTheme(); // always hook 2
if (!isLoggedIn) return <Login />;
return <Dashboard user={user} theme={theme} />;
}useEffect fires asynchronously after the browser has painted. useLayoutEffect fires synchronously after the DOM mutations but before the browser paints — the same timing as class component componentDidMount/componentDidUpdate. Use useLayoutEffect when you need to read DOM dimensions and apply corrections before the user sees the result (avoiding a visual flash).
function Tooltip({ children, text }) {
const ref = React.useRef(null);
const [pos, setPos] = React.useState({ top: 0, left: 0 });
// useLayoutEffect — measure BEFORE paint to avoid flicker
React.useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
setPos({ top: rect.bottom + 8, left: rect.left });
}, []);
// useEffect — would cause visible flicker: rendered at 0,0 then moved
return (
<>
<span ref={ref}>{children}</span>
<div className="tooltip" style={pos}>{text}</div>
</>
);
}useId generates a unique, stable ID that matches between server and client renders (avoiding SSR hydration mismatches). Use it to link form labels to inputs via htmlFor/id — never for list keys. Each useId call in the same component gets a unique value.
function FormField({ label, type = 'text', ...inputProps }) {
const id = React.useId(); // e.g. ':r0:' — stable across renders
return (
<div className="field">
<label htmlFor={id}>{label}</label>
<input id={id} type={type} {...inputProps} />
</div>
);
}
// Each instance gets its own unique id — no collisions
<FormField label="Email" type="email" /> {/* id=":r0:" */}
<FormField label="Password" type="password" /> {/* id=":r1:" */}A controlled input has both value and onChange bound to React state. The DOM input never owns the value — React is the single source of truth. This makes it trivial to validate on every keystroke, format input, conditionally disable submission, and prepopulate fields from server data.
function LoginForm() {
const [form, setForm] = React.useState({ email: '', password: '' });
const [errors, setErrors] = React.useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setForm(f => ({ ...f, [name]: value }));
setErrors(e => ({ ...e, [name]: '' })); // clear field error on change
};
const handleSubmit = (e) => {
e.preventDefault();
if (!form.email.includes('@')) return setErrors({ email: 'Invalid email' });
login(form);
};
return (
<form onSubmit={handleSubmit}>
<input name="email" value={form.email} onChange={handleChange} />
<input name="password" value={form.password} onChange={handleChange} type="password" />
<button type="submit">Login</button>
</form>
);
}React wraps native DOM events in a SyntheticEvent object that presents a consistent API across browsers — no more event.which vs event.keyCode, no window.event fallbacks. React 17+ uses event delegation on the root container (not document). Events pool in legacy React but not React 17+, so async access to event properties is safe.
function SearchBox() {
const handleKeyDown = (e) => {
if (e.key === 'Enter') submitSearch(); // consistent across browsers
if (e.key === 'Escape') clearSearch();
if (e.ctrlKey && e.key === 'k') openModal(); // modifier keys
};
const handlePaste = (e) => {
const text = e.clipboardData.getData('text');
// Safe to use async — React 17+ no longer pools events
setTimeout(() => console.log(text), 0);
};
return <input onKeyDown={handleKeyDown} onPaste={handlePaste} />;
}React Hook Form stores field values in uncontrolled DOM inputs (via refs) rather than React state. Only validation errors trigger re-renders — not every keystroke. This dramatically reduces re-render count for large forms (50+ fields) and is the de-facto standard for complex form handling.
import { useForm } from 'react-hook-form';
function RegisterForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm();
const onSubmit = async (data) => {
await registerUser(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email', {
required: 'Email is required',
pattern: { value: /\S+@\S+\.\S+/, message: 'Invalid email' }
})}
type="email"
/>
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password', { required: true, minLength: 8 })} type="password" />
<button disabled={isSubmitting}>Register</button>
</form>
);
}React 17+ attaches a single listener to the root DOM container. Clicks on any element bubble up to that listener. Call e.stopPropagation() to prevent an event from reaching ancestor React handlers. Be careful with stopPropagation — it also prevents non-React listeners (analytics, third-party libraries) from seeing the event.
function Dropdown({ trigger, children }) {
const [open, setOpen] = React.useState(false);
// Close on backdrop click
React.useEffect(() => {
if (!open) return;
const close = () => setOpen(false);
document.addEventListener('click', close);
return () => document.removeEventListener('click', close);
}, [open]);
return (
<div>
<button onClick={() => setOpen(o => !o)}>{trigger}</button>
{open && (
<ul onClick={e => e.stopPropagation()}> {/* prevent closing when clicking items */}
{children}
</ul>
)}
</div>
);
}Zod defines a validation schema as a type-safe object. @hookform/resolvers/zod bridges Zod and React Hook Form — the schema validates the entire form on submit and produces per-field error messages. TypeScript infers the form data type directly from the schema, eliminating duplicate type definitions.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Min 8 characters'),
age: z.number({ coerce: true }).int().min(13)
});
type FormData = z.infer<typeof schema>; // { email: string; password: string; age: number }
function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema)
});
return (
<form onSubmit={handleSubmit(data => createAccount(data))}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('age')} type="number" />
<button type="submit">Sign up</button>
</form>
);
}Without debouncing, every keystroke triggers an API call. Debouncing delays the call until the user has stopped typing for N milliseconds. The cleanest React approach: store the raw input in state (for responsive UI), debounce via useEffect + a timeout, and only call the API with the debounced value.
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = React.useState(value);
React.useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer); // cancel on next keystroke
}, [value, delay]);
return debounced;
}
function SearchPage() {
const [query, setQuery] = React.useState('');
const debouncedQuery = useDebounce(query, 400);
// Only fires 400ms after typing stops
const { data } = useFetch(debouncedQuery ? `/api/search?q=${debouncedQuery}` : null);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}File inputs are inherently uncontrolled — you can’t set their value. Read the file via the onChange event’s FileList. Generate a preview URL with URL.createObjectURL and revoke it on cleanup to avoid memory leaks.
function AvatarUpload() {
const [preview, setPreview] = React.useState(null);
const [file, setFile] = React.useState(null);
const handleChange = (e) => {
const selected = e.target.files[0];
if (!selected) return;
setFile(selected);
const url = URL.createObjectURL(selected);
setPreview(url);
};
React.useEffect(() => {
return () => { if (preview) URL.revokeObjectURL(preview); }; // cleanup memory
}, [preview]);
const handleUpload = async () => {
const fd = new FormData();
fd.append('avatar', file);
await fetch('/api/avatar', { method: 'POST', body: fd });
};
return (
<>
<input type="file" accept="image/*" onChange={handleChange} />
{preview && <img src={preview} alt="Preview" style={{ width: 100 }} />}
{file && <button onClick={handleUpload}>Upload</button>}
</>
);
}React 19 introduces the useActionState hook (formerly useFormState) and native async action support on <form>. The form’s action prop accepts an async function. React automatically handles pending state and error capture — no manual useState for loading/error.
import { useActionState } from 'react';
async function submitContact(prevState, formData) {
const email = formData.get('email');
const message = formData.get('message');
try {
await sendContactEmail({ email, message });
return { success: true };
} catch (e) {
return { success: false, error: e.message };
}
}
function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, null);
return (
<form action={formAction}>
<input name="email" type="email" required />
<textarea name="message" required />
<button disabled={isPending}>{isPending ? 'Sending…' : 'Send'}</button>
{state?.success && <p>Message sent!</p>}
{state?.error && <p style={{color:'red'}}>{state.error}</p>}
</form>
);
}React uses keys to match elements between renders. Array index keys cause bugs when items are added to the beginning, removed from the middle, or reordered — the key stays with the position, not the item, so React incorrectly reuses DOM nodes and input/focus state. Use a stable data-derived ID.
// ❌ Index key — breaks when items are added/removed/sorted
{todos.map((todo, i) => <TodoItem key={i} todo={todo} />)}
// ✅ Stable data ID
{todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}
// When no ID exists, derive a stable key from content
{options.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}Ternary (? :): best for switching between two different outputs. Short-circuit (&&): renders or renders nothing — but beware: if the left side is 0, React renders the number 0. Early return: clearest for complex guard conditions that bail out before the main UI. Never use if inside JSX — extract to a variable.
// Ternary — two branches
{isLoggedIn ? <Dashboard /> : <Login />}
// && — render or nothing (use !! to coerce falsy to boolean)
{!!items.length && <List items={items} />} // !! prevents rendering '0'
// Early return — clearest for guards
function Page({ user, loading }) {
if (loading) return <Spinner />;
if (!user) return <NotFound />;
return <Dashboard user={user} />;
}
// Variable — complex conditions
const badge = count > 99 ? '99+' : count > 0 ? count : null;
return <button>Notifications {badge && <span>{badge}</span>}</button>;Rendering 10,000 rows creates 10,000 DOM nodes — slow initial render and heavy scroll performance. Virtualization renders only the rows currently visible in the viewport (+ a buffer). react-window and react-virtual (TanStack Virtual) are the most common options.
import { FixedSizeList } from 'react-window';
function Row({ index, style, data }) {
const item = data[index];
// style positions the row — must be applied
return (
<div style={style} className="row">
{item.name} — {item.email}
</div>
);
}
function UserTable({ users }) {
return (
<FixedSizeList
height={600}
itemCount={users.length}
itemSize={48} // px per row
itemData={users}
width="100%"
>
{Row}
</FixedSizeList>
);
}Use IntersectionObserver on a sentinel element at the bottom of the list. When the sentinel becomes visible, trigger a fetch for the next page of results and append them to the current list. Clean up the observer on unmount.
function useInfiniteScroll(loadMore) {
const sentinelRef = React.useRef(null);
React.useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) loadMore(); },
{ threshold: 0.1 }
);
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [loadMore]);
return sentinelRef;
}
function Feed() {
const [pages, setPages] = React.useState([1]);
const loadMore = React.useCallback(() => setPages(p => [...p, p.length + 1]), []);
const sentinelRef = useInfiniteScroll(loadMore);
return (
<div>
{pages.map(page => <PageOfPosts key={page} page={page} />)}
<div ref={sentinelRef} style={{ height: 1 }} />
</div>
);
}Store selected IDs in a Set inside state for O(1) lookup. Use a Set → array copy pattern for immutable updates (React requires new references). Derive “select all” state from comparing sizes.
function CheckboxList({ items }) {
const [selected, setSelected] = React.useState(new Set());
const toggle = (id) => setSelected(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const toggleAll = () => setSelected(
selected.size === items.length ? new Set() : new Set(items.map(i => i.id))
);
return (
<ul>
<li>
<input type="checkbox" checked={selected.size === items.length} onChange={toggleAll} />
<label>Select all</label>
</li>
{items.map(item => (
<li key={item.id}>
<input type="checkbox" checked={selected.has(item.id)} onChange={() => toggle(item.id)} />
<label>{item.name}</label>
</li>
))}
</ul>
);
}React.Children.map iterates children safely — handles null, single elements, and arrays uniformly. Used by compound component patterns to inject props (like isActive or index) into children without passing them explicitly at the call site.
function Tabs({ children }) {
const [activeIndex, setActiveIndex] = React.useState(0);
const tabs = React.Children.map(children, (child, i) =>
React.cloneElement(child, {
isActive: i === activeIndex,
onClick: () => setActiveIndex(i)
})
);
return <div className="tabs">{tabs}</div>;
}
function Tab({ isActive, onClick, children }) {
return <button className={isActive ? 'active' : ''} onClick={onClick}>{children}</button>;
}
// Usage — Tabs injects isActive/onClick automatically
<Tabs>
<Tab>Overview</Tab>
<Tab>Details</Tab>
<Tab>Reviews</Tab>
</Tabs>createContext creates a context object. A Provider wraps the subtree that needs access to the value. useContext subscribes a component to that value — the component re-renders whenever the context value changes. Wrap the provider in a custom hook to keep the API clean and enforce usage within the provider.
const ThemeContext = React.createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = React.useState('light');
const toggle = () => setTheme(t => t === 'light' ? 'dark' : 'light');
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = React.useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
return ctx;
}
// Any descendant — no props required
function Header() {
const { theme, toggle } = useTheme();
return <header className={theme}><button onClick={toggle}>Toggle</button></header>;
}Every consumer of a context re-renders when the context value changes. If the same context provides both state and dispatch/setters, a component that only calls a setter still re-renders when unrelated state changes. Split into two contexts: a stable dispatch context and a state context. Dispatch functions never change, so dispatch-only consumers never re-render.
const CartStateContext = React.createContext(null);
const CartDispatchContext = React.createContext(null);
function CartProvider({ children }) {
const [items, dispatch] = React.useReducer(cartReducer, []);
return (
<CartDispatchContext.Provider value={dispatch}> {/* stable ref */}
<CartStateContext.Provider value={items}>
{children}
</CartStateContext.Provider>
</CartDispatchContext.Provider>
);
}
// AddToCart only re-renders when dispatch changes (never)
function AddToCart({ productId }) {
const dispatch = React.useContext(CartDispatchContext);
return <button onClick={() => dispatch({ type: 'ADD', id: productId })}>Add</button>;
}Zustand creates a store as a hook. State and actions live in the same definition. Components subscribe to slices of state — only re-render when the selected slice changes, without a Provider wrapper. No reducers, no action creators, no connect HOC.
import { create } from 'zustand';
const useCartStore = create((set, get) => ({
items: [],
total: 0,
addItem: (item) => set(state => ({
items: [...state.items, item],
total: state.total + item.price
})),
removeItem: (id) => set(state => {
const items = state.items.filter(i => i.id !== id);
return { items, total: items.reduce((s, i) => s + i.price, 0) };
}),
clearCart: () => set({ items: [], total: 0 })
}));
// Subscribed to total only — won't re-render on items change
function CartBadge() {
const total = useCartStore(state => state.total);
return <span>${total.toFixed(2)}</span>;
}Redux Toolkit eliminates boilerplate: createSlice auto-generates action creators and handles immutable updates via Immer (you write mutating code, Immer produces a new object). createAsyncThunk handles the async lifecycle (pending/fulfilled/rejected) automatically.
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUser = createAsyncThunk('user/fetch', async (id) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
});
const userSlice = createSlice({
name: 'user',
initialState: { data: null, status: 'idle', error: null },
reducers: {
clearUser: state => { state.data = null; } // Immer: mutate directly
},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, state => { state.status = 'loading'; })
.addCase(fetchUser.fulfilled, (state, action) => { state.status = 'success'; state.data = action.payload; })
.addCase(fetchUser.rejected, (state, action) => { state.status = 'error'; state.error = action.error.message; });
}
});
export const { clearUser } = userSlice.actions;
export default userSlice.reducer;Jotai represents state as individual atoms — tiny pieces of state akin to useState but shareable. Components subscribe to specific atoms and only re-render when those atoms change. Derived atoms compute from other atoms, similar to computed properties. No store, no selector boilerplate — atoms compose bottom-up.
import { atom, useAtom, useAtomValue } from 'jotai';
const priceAtom = atom(100);
const quantityAtom = atom(1);
const totalAtom = atom((get) => get(priceAtom) * get(quantityAtom)); // derived
function PriceInput() {
const [price, setPrice] = useAtom(priceAtom);
return <input type="number" value={price} onChange={e => setPrice(+e.target.value)} />;
}
function Total() {
const total = useAtomValue(totalAtom); // read-only subscription
return <strong>Total: ${total}</strong>;
}Zustand’s persist middleware serializes the store to localStorage on every change and rehydrates on page load. Specify which keys to persist via partialize — avoid persisting derived data or large objects. Version the storage to handle breaking schema changes.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
const useUserStore = create(
persist(
(set) => ({
user: null,
token: null,
setUser: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null })
}),
{
name: 'user-storage', // localStorage key
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ user: state.user, token: state.token }), // don't persist functions
version: 1, // increment to clear stale state on schema change
migrate: (state, version) => version === 0 ? { ...state, user: null } : state
}
)
);For medium-complexity apps that don’t need Redux DevTools or middleware, combining useReducer (for structured state updates) with Context (for global accessibility) achieves most of what Redux provides. The reducer is testable pure function; Context distributes state and dispatch to any descendant.
function appReducer(state, action) {
switch (action.type) {
case 'LOGIN': return { ...state, user: action.payload, isAuth: true };
case 'LOGOUT': return { ...state, user: null, isAuth: false };
default: return state;
}
}
const AppContext = React.createContext(null);
export function AppProvider({ children }) {
const [state, dispatch] = React.useReducer(appReducer, { user: null, isAuth: false });
const value = React.useMemo(() => ({ state, dispatch }), [state]);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
export const useApp = () => React.useContext(AppContext);
// In any component
const { state, dispatch } = useApp();
dispatch({ type: 'LOGIN', payload: user });Search params (?tab=reviews&page=2) are global, bookmarkable, and shareable state. Components read from and write to the URL directly — changes are reflected in all subscribers without a separate state store. React Router’s useSearchParams provides a hook-based API.
import { useSearchParams } from 'react-router-dom';
function ProductPage() {
const [params, setParams] = useSearchParams();
const tab = params.get('tab') || 'overview';
const page = parseInt(params.get('page') || '1');
const setTab = (t) => setParams(p => { p.set('tab', t); p.delete('page'); return p; });
const setPage = (n) => setParams(p => { p.set('page', String(n)); return p; });
return (
<>
<TabBar active={tab} onChange={setTab} />
<TabContent tab={tab} />
<Pagination current={page} onChange={setPage} />
</>
);
}React.lazy wraps a dynamic import() to create a component that is loaded only when first rendered. Suspense shows a fallback while the chunk loads. This splits the bundle at route or component boundaries — users only download the code they actually visit. Essential for large apps.
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// Each route is a separate chunk — loaded on demand
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Reports = lazy(() => import('./pages/Reports'));
function App() {
return (
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
);
}The Profiler tab in React DevTools records a commit, then shows a flamegraph of render time per component. Gray bars = skipped (memoized). Colored bars = rendered, with width proportional to time. Click a bar to see why it rendered (prop/state change). This is the starting point for all React performance work.
// Programmatic profiling — measure specific trees
import { Profiler } from 'react';
function onRender(id, phase, actualDuration) {
console.log(`${id} [${phase}] took ${actualDuration.toFixed(2)}ms`);
}
<Profiler id="ProductList" onRender={onRender}>
<ProductList items={items} />
</Profiler>
// Also useful: why-did-you-render library
// import whyDidYouRender from '@welldone-software/why-did-you-render';
// whyDidYouRender(React, { trackAllPureComponents: true });startTransition marks a state update as “non-urgent”. React can interrupt the transition render to handle higher-priority updates (typing, clicking). The UI stays responsive while a slow render (filtering 50,000 items) processes in the background. Show a stale indicator with useTransition‘s isPending flag.
import { useTransition } from 'react';
function FilterableList({ items }) {
const [query, setQuery] = React.useState('');
const [filtered, setFiltered] = React.useState(items);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
setQuery(e.target.value); // urgent — input stays responsive
startTransition(() => { // non-urgent — can be interrupted
const q = e.target.value.toLowerCase();
setFiltered(items.filter(i => i.name.toLowerCase().includes(q)));
});
}
return (
<>
<input value={query} onChange={handleChange} />
<div style={{ opacity: isPending ? 0.6 : 1 }}>
{filtered.map(i => <Row key={i.id} item={i} />)}
</div>
</>
);
}Images below the fold don’t need to load immediately. Native loading="lazy" tells the browser to defer loading until the image is near the viewport. Combine with explicit width/height to prevent layout shift (CLS). For Next.js, use the <Image> component which handles all this automatically.
// Native lazy loading — no JS needed
function ProductImage({ src, alt, width, height }) {
return (
<img
src={src}
alt={alt}
width={width}
height={height}
loading="lazy" // defer until near viewport
decoding="async" // non-blocking decode
style={{ aspectRatio: `${width}/${height}` }} // reserve space — prevents CLS
/>
);
}
// Progressive enhancement with blur-up placeholder
function BlurImage({ src, placeholder, ...rest }) {
const [loaded, setLoaded] = React.useState(false);
return (
<div style={{ position: 'relative' }}>
<img src={placeholder} style={{ filter: loaded ? 'none' : 'blur(20px)', transition: 'filter 0.3s' }} {...rest} />
<img src={src} onLoad={() => setLoaded(true)} style={{ opacity: loaded ? 1 : 0 }} {...rest} />
</div>
);
}useDeferredValue accepts a value and returns a deferred copy of it. When the value updates, React first renders with the old deferred value (instant), then renders again with the new value in the background. Unlike startTransition, you use it when you receive a value you didn’t trigger (e.g., from a parent).
import { useDeferredValue, memo } from 'react';
// Expensive child that takes 100ms to render
const ExpensiveChart = memo(({ data }) => {
// heavy computation...
return <canvas ref={drawChart(data)} />;
});
function Dashboard({ liveData }) {
const deferredData = useDeferredValue(liveData);
const isStale = liveData !== deferredData;
return (
<div>
<DataTable data={liveData} /> {/* shows live data immediately */}
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<ExpensiveChart data={deferredData} /> {/* renders with deferred data */}
</div>
</div>
);
}Build the production bundle, then analyze it to find which modules take the most space. Common culprits: moment.js (500 KB — replace with date-fns), lodash (import individual functions), duplicate packages, heavy chart libraries. Use tree-shakeable imports and dynamic imports for routes.
# Create React App
npx source-map-explorer 'build/static/js/*.js'
# Vite — built-in rollup visualizer
npm install --save-dev rollup-plugin-visualizer
# vite.config.js: plugins: [visualizer({ open: true })]
npm run build # opens treemap in browser
# Fixes:
# ❌ import _ from 'lodash' → ✅ import debounce from 'lodash/debounce'
# ❌ import moment from 'moment' → ✅ import { format } from 'date-fns'
# ❌ import * as icons from 'lucide' → ✅ import { Search } from 'lucide-react'Lazy-loaded routes cause a visible delay on first navigation while the chunk downloads. Pre-load likely-next routes on hover or when the network is idle — the chunk is cached when the user navigates. A simple custom preloader calls the dynamic import without rendering the component.
const loadDashboard = () => import('./pages/Dashboard');
const Dashboard = React.lazy(loadDashboard);
function NavLink({ to, preload, children, ...rest }) {
return (
<Link
to={to}
onMouseEnter={preload} // preload chunk on hover — usually loads before click
onFocus={preload} // keyboard navigation
{...rest}
>
{children}
</Link>
);
}
// Usage
<NavLink to="/dashboard" preload={loadDashboard}>Dashboard</NavLink>Web Workers run JavaScript in a background thread, communicating via message passing. Heavy synchronous work (CSV parsing, image processing, diff computation) blocks the main thread and freezes the UI. Moving it to a worker keeps React rendering and user interactions smooth.
// worker.js
self.onmessage = ({ data }) => {
const result = heavyComputation(data.input);
self.postMessage({ result });
};
// React component
function useWorker(workerPath) {
const workerRef = React.useRef(null);
React.useEffect(() => {
workerRef.current = new Worker(new URL(workerPath, import.meta.url));
return () => workerRef.current.terminate();
}, [workerPath]);
return React.useCallback((input) => new Promise((resolve) => {
workerRef.current.onmessage = ({ data }) => resolve(data.result);
workerRef.current.postMessage({ input });
}), []);
}
function DataProcessor() {
const runWorker = useWorker('./worker.js');
const process = async () => {
const result = await runWorker(bigDataset); // non-blocking
setOutput(result);
};
}Parent routes with an <Outlet /> act as layout shells. Child routes render inside <Outlet>, keeping the parent’s navigation, sidebars, and headers mounted. Index routes define what renders at the parent path when no child is matched.
import { Routes, Route, Outlet, Link } from 'react-router-dom';
function DashboardLayout() {
return (
<div className="layout">
<Sidebar />
<main>
<Outlet /> {/* child route renders here */}
</main>
</div>
);
}
function App() {
return (
<Routes>
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<DashboardHome />} /> {/* /dashboard */}
<Route path="orders" element={<OrderList />} /> {/* /dashboard/orders */}
<Route path="settings"element={<Settings />} /> {/* /dashboard/settings */}
</Route>
</Routes>
);
}A ProtectedRoute component checks auth state and redirects to login if not authenticated. Wrap private route groups with it. Pass the intended destination in state so the user returns to their original page after login.
import { Navigate, useLocation } from 'react-router-dom';
function ProtectedRoute({ children }) {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) return <Spinner />; // wait for auth check
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
return children;
}
// In routes
<Route path="/dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
<Route index element={<DashboardHome />} />
</Route>
// After login — redirect back to intended page
function Login() {
const location = useLocation();
const navigate = useNavigate();
const from = location.state?.from?.pathname || '/dashboard';
const handleLogin = async () => { await login(); navigate(from, { replace: true }); };
}React Router v6.4+ loaders run before the route component renders, eliminating the loading spinner pattern (fetch in useEffect → renders spinner → data arrives). Data is available immediately when the component mounts. Errors throw to the nearest errorElement.
import { createBrowserRouter, useLoaderData, useParams } from 'react-router-dom';
async function orderLoader({ params }) {
const res = await fetch(`/api/orders/${params.orderId}`);
if (!res.ok) throw new Response('Not found', { status: 404 });
return res.json();
}
function OrderDetail() {
const order = useLoaderData(); // data is already here — no loading state needed
return <OrderView order={order} />;
}
const router = createBrowserRouter([{
path: '/orders/:orderId',
loader: orderLoader,
element: <OrderDetail />,
errorElement: <OrderError />
}]);useNavigate returns a function to navigate imperatively — after form submissions, button clicks, or async operations. Pass a number to go back/forward in history. Pass replace: true to replace the current history entry (no back navigation to the previous page).
import { useNavigate } from 'react-router-dom';
function CreateOrder() {
const navigate = useNavigate();
const handleSubmit = async (data) => {
const order = await createOrder(data);
navigate(`/orders/${order.id}`, { replace: true }); // go to new order, no back to form
};
const handleCancel = () => navigate(-1); // browser back
return (
<form onSubmit={handleSubmit(handleFormSubmit)}>
{/* ... */}
<button type="button" onClick={handleCancel}>Cancel</button>
<button type="submit">Create</button>
</form>
);
}NavLink automatically adds an active class (or whatever you name it) when its to prop matches the current URL. The className and style props can be functions that receive { isActive, isPending } for full control. end prop prevents parent routes from matching nested paths.
import { NavLink } from 'react-router-dom';
function Sidebar() {
const navClass = ({ isActive }) =>
`nav-link ${isActive ? 'nav-link--active' : ''}`;
return (
<nav>
<NavLink to="/" className={navClass} end>Home</NavLink>
<NavLink to="/dashboard" className={navClass}>Dashboard</NavLink>
<NavLink to="/orders" className={navClass}>Orders</NavLink>
<NavLink
to="/settings"
style={({ isActive }) => ({ fontWeight: isActive ? 700 : 400 })}
>
Settings
</NavLink>
</nav>
);
}Each route chunk loads independently. Wrap the router’s Routes in a single Suspense boundary at the top for a global fallback, or add per-route boundaries for granular loading UX. Combine with React Router loaders to avoid the flash of empty content.
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
function App() {
return (
<BrowserRouter>
{/* Global fallback for top-level navigation */}
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={
{/* Per-route fallback */}
<Suspense fallback={<ProfileSkeleton />}>
<Profile />
</Suspense>
} />
</Routes>
</Suspense>
</BrowserRouter>
);
}React Router doesn’t scroll to top on navigation by default. A ScrollRestoration component (v6.4+) or a custom hook resets scroll position on route changes. Animate transitions with CSS or Framer Motion to smooth the visual context change.
// v6.4 data router — built-in scroll restoration
import { ScrollRestoration } from 'react-router-dom';
function Root() {
return <><Outlet /><ScrollRestoration /></>;
}
// Manual scroll-to-top hook (v5 / older v6)
function ScrollToTop() {
const { pathname } = useLocation();
React.useEffect(() => { window.scrollTo(0, 0); }, [pathname]);
return null;
}
// Framer Motion page transition
import { AnimatePresence, motion } from 'framer-motion';
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<Routes location={location}>{/* routes */}</Routes>
</motion.div>
</AnimatePresence>useBlocker intercepts navigation (link clicks, browser back) and lets you show a confirmation dialog. The blocker exposes proceed() and reset() to confirm or cancel. Only activate the blocker when there are actually unsaved changes.
import { useBlocker } from 'react-router-dom';
function EditForm() {
const [isDirty, setIsDirty] = React.useState(false);
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname
);
return (
<>
<form onChange={() => setIsDirty(true)}>{/* ... */}</form>
{blocker.state === 'blocked' && (
<ConfirmDialog
message="You have unsaved changes. Leave anyway?"
onConfirm={blocker.proceed}
onCancel={blocker.reset}
/>
)}
</>
);
}TanStack Query (React Query) manages the full async data lifecycle: deduplication of simultaneous requests, caching with configurable stale time, background refetching on window focus, pagination, and mutations with optimistic updates. Replaces dozens of lines of useEffect/useState with a single hook.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function OrderList() {
const { data, isLoading, error } = useQuery({
queryKey: ['orders'],
queryFn: () => fetch('/api/orders').then(r => r.json()),
staleTime: 30_000, // consider data fresh for 30s — no refetch on remount
});
const queryClient = useQueryClient();
const { mutate: deleteOrder } = useMutation({
mutationFn: (id) => fetch(`/api/orders/${id}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['orders'] }) // refetch
});
if (isLoading) return <Spinner />;
if (error) return <Error msg={error.message} />;
return <ul>{data.map(o => <OrderRow key={o.id} order={o} onDelete={deleteOrder} />)}</ul>;
}Optimistic updates immediately show the expected result of a mutation in the UI, then reconcile with the server response. If the request fails, roll back to the previous state. TanStack Query’s onMutate/onError/onSettled lifecycle handles this pattern cleanly.
const { mutate: toggleLike } = useMutation({
mutationFn: (postId) => fetch(`/api/posts/${postId}/like`, { method: 'POST' }),
onMutate: async (postId) => {
await queryClient.cancelQueries({ queryKey: ['posts'] });
const previous = queryClient.getQueryData(['posts']); // snapshot
queryClient.setQueryData(['posts'], (old) =>
old.map(p => p.id === postId ? { ...p, liked: !p.liked } : p)
);
return { previous }; // context for rollback
},
onError: (err, postId, context) => {
queryClient.setQueryData(['posts'], context.previous); // rollback
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['posts'] })
});SWR (from Vercel) implements the HTTP stale-while-revalidate strategy: show cached data immediately, then silently fetch fresh data in the background and update the UI. Simpler API than React Query for basic use cases; supports global config, conditional fetching, and polling.
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then(r => r.json());
function Profile({ userId }) {
const { data, error, isLoading, mutate } = useSWR(
`/api/users/${userId}`,
fetcher,
{
refreshInterval: 30_000, // poll every 30s
revalidateOnFocus: true, // refetch when tab regains focus
dedupingInterval: 2_000 // deduplicate requests within 2s
}
);
if (isLoading) return <Skeleton />;
if (error) return <ErrorBanner />;
return (
<div>
<Avatar user={data} />
<button onClick={() => mutate()}>Refresh</button>
</div>
);
}Include the page number in the query key — TanStack Query caches each page separately. Set placeholderData: keepPreviousData to show the current page’s data while the next page loads, eliminating the loading flash on pagination.
import { useQuery, keepPreviousData } from '@tanstack/react-query';
function OrdersTable() {
const [page, setPage] = React.useState(1);
const { data, isFetching } = useQuery({
queryKey: ['orders', page],
queryFn: () => fetch(`/api/orders?page=${page}&limit=20`).then(r => r.json()),
placeholderData: keepPreviousData, // show old data while next page loads
});
return (
<div style={{ opacity: isFetching ? 0.7 : 1 }}>
<table>{data?.items.map(o => <OrderRow key={o.id} order={o} />)}</table>
<Pagination
current={page}
total={data?.totalPages}
onChange={setPage}
/>
</div>
);
}If a component unmounts while a fetch is in-flight, setting state on an unmounted component causes a React warning and potential bugs. Return a cleanup function from useEffect that aborts the request — the fetch rejects with an AbortError, which you can safely ignore.
function UserProfile({ userId }) {
const [user, setUser] = React.useState(null);
React.useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
// AbortError is expected on cleanup — silently ignore
});
return () => controller.abort(); // cancel on unmount or userId change
}, [userId]);
return user ? <ProfileCard user={user} /> : <Skeleton />;
}Rather than repeating auth header injection and error handling in every fetch call, create a hook that wraps fetch with the base URL, token, and error normalization. The hook uses useCallback so the returned function has a stable reference.
function useApi() {
const { token } = useAuth();
const request = React.useCallback(async (path, options = {}) => {
const res = await fetch(`${import.meta.env.VITE_API_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : undefined,
...options.headers
}
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw Object.assign(new Error(err.message), { status: res.status });
}
return res.status === 204 ? null : res.json();
}, [token]);
return { request };
}Open a WebSocket in a useEffect and update state on each message. Clean up the connection on unmount. Use useRef for the WebSocket instance to avoid recreating it on unrelated re-renders.
function useLiveOrders() {
const [orders, setOrders] = React.useState([]);
const wsRef = React.useRef(null);
React.useEffect(() => {
wsRef.current = new WebSocket(`${WS_URL}/orders`);
wsRef.current.onmessage = ({ data }) => {
const event = JSON.parse(data);
if (event.type === 'ORDER_CREATED')
setOrders(prev => [event.order, ...prev]);
if (event.type === 'ORDER_UPDATED')
setOrders(prev => prev.map(o => o.id === event.order.id ? event.order : o));
};
return () => wsRef.current?.close();
}, []);
const send = (msg) => wsRef.current?.send(JSON.stringify(msg));
return { orders, send };
}useInfiniteQuery manages a list of pages. Each page is fetched via queryFn with a cursor from the previous page. fetchNextPage() loads more. Combine with an IntersectionObserver sentinel for automatic loading as the user scrolls.
import { useInfiniteQuery } from '@tanstack/react-query';
function Feed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) =>
fetch(`/api/feed?cursor=${pageParam ?? ''}`).then(r => r.json()),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined
});
const sentinelRef = React.useRef(null);
React.useEffect(() => {
const ob = new IntersectionObserver(([e]) => { if (e.isIntersecting && hasNextPage) fetchNextPage(); });
if (sentinelRef.current) ob.observe(sentinelRef.current);
return () => ob.disconnect();
}, [hasNextPage, fetchNextPage]);
return (
<div>
{data?.pages.flatMap(p => p.items).map(item => <FeedItem key={item.id} item={item} />)}
<div ref={sentinelRef}>{isFetchingNextPage && <Spinner />}</div>
</div>
);
}Error Boundaries must be class components — there’s no hook equivalent yet. They catch rendering errors in their children and display a fallback UI instead of crashing the whole app. Use the react-error-boundary library for a hook-friendly wrapper and error recovery support.
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
function App() {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => queryClient.clear()} // reset data on retry
onError={(error) => logger.capture(error)}
>
<Dashboard />
</ErrorBoundary>
);
}Compound components are a set of sub-components that share implicit state through Context. The parent manages state; each sub-component accesses only what it needs. The API reads like natural HTML (Select → Option) but with React flexibility.
const SelectCtx = React.createContext(null);
function Select({ value, onChange, children }) {
return (
<SelectCtx.Provider value={{ value, onChange }}>
<div className="select">{children}</div>
</SelectCtx.Provider>
);
}
function Option({ value, children }) {
const { value: selected, onChange } = React.useContext(SelectCtx);
return (
<div
className={`option ${selected === value ? 'selected' : ''}`}
onClick={() => onChange(value)}
>
{children}
</div>
);
}
Select.Option = Option; // attach as static property
// Clean usage
<Select value={size} onChange={setSize}>
<Select.Option value="sm">Small</Select.Option>
<Select.Option value="md">Medium</Select.Option>
<Select.Option value="lg">Large</Select.Option>
</Select>Function components don’t accept a ref prop by default. forwardRef wraps the component so the parent can attach a ref to the underlying DOM node or an imperative handle. Combine with useImperativeHandle to expose a curated API instead of the raw DOM node.
// Forward ref to the native input
const Input = React.forwardRef(function Input({ label, ...rest }, ref) {
return (
<div>
<label>{label}</label>
<input ref={ref} {...rest} />
</div>
);
});
// Expose custom imperative API
const FancyInput = React.forwardRef(function FancyInput(props, ref) {
const inputRef = React.useRef(null);
React.useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => { if (inputRef.current) inputRef.current.value = ''; }
}));
return <input ref={inputRef} {...props} />;
});
// Parent usage
const ref = React.useRef(null);
<FancyInput ref={ref} />;
ref.current.focus(); // calls the imperative handleReactDOM.createPortal renders children into a different DOM node than the component tree — typically document.body for modals, tooltips, and dropdowns. Events still bubble through the React tree (not the DOM tree), so event handling and context work normally.
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<button onClick={onClose} aria-label="Close">×</button>
{children}
</div>
</div>,
document.body // rendered outside app root — no z-index overflow issues
);
}Render props pass a function as a prop; the component calls it with its state, letting the caller control rendering. Largely superseded by custom hooks in modern React — but still useful when you need to share JSX-level logic (like mouse position that drives complex rendering).
// Render prop component
function MouseTracker({ render }) {
const [pos, setPos] = React.useState({ x: 0, y: 0 });
const handleMove = (e) => setPos({ x: e.clientX, y: e.clientY });
return (
<div onMouseMove={handleMove} style={{ height: '100vh' }}>
{render(pos)}
</div>
);
}
// Caller controls how position is displayed
<MouseTracker render={({ x, y }) => (
<img src="cursor.svg" style={{ position: 'fixed', left: x, top: y }} alt="" />
)} />
// Modern equivalent — custom hook (preferred)
function useMouse() {
const [pos, setPos] = React.useState({ x: 0, y: 0 });
React.useEffect(() => {
const handler = (e) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, []);
return pos;
}Instead of exposing the raw DOM node (which lets the parent do anything), useImperativeHandle restricts the parent to a curated set of methods. This maintains encapsulation — the child controls its own DOM while providing a safe external API.
const VideoPlayer = React.forwardRef(function VideoPlayer({ src }, ref) {
const videoRef = React.useRef(null);
React.useImperativeHandle(ref, () => ({
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seek: (t) => { if (videoRef.current) videoRef.current.currentTime = t; },
getTime: () => videoRef.current?.currentTime ?? 0
// Parent cannot access DOM directly — only these methods
}));
return <video ref={videoRef} src={src} />;
});
// Parent
const playerRef = React.useRef(null);
<VideoPlayer ref={playerRef} src={videoSrc} />
<button onClick={() => playerRef.current.play()}>Play</button>An HOC is a function that takes a component and returns a new component with added props or behavior (auth checks, analytics, logging). Largely replaced by hooks in modern React — but HOCs still appear in older codebases and some libraries (Redux’s connect, React Router v5’s withRouter).
// HOC — adds auth protection to any component
function withAuth(WrappedComponent) {
return function AuthenticatedComponent(props) {
const { user, isLoading } = useAuth();
if (isLoading) return <Spinner />;
if (!user) return <Navigate to="/login" />;
return <WrappedComponent {...props} user={user} />;
};
}
const ProtectedDashboard = withAuth(Dashboard);
// Modern equivalent — custom hook (preferred for new code)
function Dashboard() {
const { user } = useRequireAuth(); // throws redirect if not authed
return <div>Welcome {user.name}</div>;
}React 19’s useOptimistic manages an “optimistic” layer of state that reflects anticipated changes. It shows the optimistic state while the async operation is in-flight and automatically reverts to the real state when the operation completes (success or failure). Cleaner than manually managing rollback logic.
import { useOptimistic, useTransition } from 'react';
function TodoList({ todos, addTodo }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state, newTodo) => [...state, { ...newTodo, pending: true }]
);
const [, startTransition] = useTransition();
const handleAdd = (text) => {
startTransition(async () => {
addOptimistic({ id: Date.now(), text }); // shows immediately
await addTodo(text); // actual server call
// on completion: optimistic state replaced by real state
});
};
return (
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.6 : 1 }}>{todo.text}</li>
))}
</ul>
);
}React 19’s use(promise) suspends the component while the promise is pending — similar to await inside a component. Unlike other hooks, use can be called conditionally. It can also replace useContext for reading context values.
import { use, Suspense } from 'react';
// Create a promise outside the component (e.g., from a loader or parent)
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
function UserCard({ userPromise }) {
const user = use(userPromise); // suspends until resolved
return <div>{user.name}</div>;
}
function App() {
const userPromise = fetchUser(1); // starts immediately — outside render
return (
<Suspense fallback={<Skeleton />}>
<UserCard userPromise={userPromise} />
</Suspense>
);
}
// use() with context — conditional calls allowed
function Badge({ showAdmin }) {
if (showAdmin) {
const admin = use(AdminContext); // ✅ conditionally calling use() is valid
return <span>{admin.name}</span>;
}
return null;
}Browser APIs (localStorage, geolocation, media queries, clipboard) are hard to test. Wrapping them in custom hooks makes them mockable in tests and reusable across components. The hook handles setup, cleanup, and serialization consistently.
function useLocalStorage(key, initialValue) {
const [value, setValue] = React.useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch { return initialValue; }
});
const set = React.useCallback((val) => {
const next = typeof val === 'function' ? val(value) : val;
setValue(next);
try { localStorage.setItem(key, JSON.stringify(next)); } catch {}
}, [key, value]);
return [value, set];
}
// Usage — persisted across page refreshes
const [theme, setTheme] = useLocalStorage('theme', 'light');
// In tests — mock localStorage
jest.spyOn(Storage.prototype, 'getItem').mockReturnValue('"dark"');RTL provides queries based on how users interact with the UI — by text content, role, label — rather than CSS selectors or component internals. Tests that pass HTML semantics checks are more resilient to refactoring and double as accessibility checks.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('submits login form with correct credentials', async () => {
const user = userEvent.setup();
const mockLogin = jest.fn().mockResolvedValue({ token: 'abc' });
render(<LoginForm onLogin={mockLogin} />);
// Query by label text — accessible and implementation-independent
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.type(screen.getByLabelText(/password/i), 'secret123');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(mockLogin).toHaveBeenCalledWith({ email: 'alice@example.com', password: 'secret123' });
});Use Mock Service Worker (MSW) to intercept real fetch/XHR calls at the network level — no mocking of fetch itself. MSW handlers run in both tests and the browser (for development). This means your test uses the same code path as production, including request building and response parsing.
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen, waitFor } from '@testing-library/react';
const server = setupServer(
http.get('/api/users/1', () =>
HttpResponse.json({ id: 1, name: 'Alice', email: 'alice@example.com' })
)
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('displays user name after fetch', async () => {
render(<UserProfile userId={1} />);
expect(screen.getByRole('status')).toBeInTheDocument(); // loading state
await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument());
});Wrap the component under test in its Provider with the desired value. Create a reusable custom render wrapper that includes all necessary Providers (Router, QueryClient, Theme) so individual tests stay clean. Override provider values per-test for edge cases.
// test-utils.jsx — custom render with all providers
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export function renderWithProviders(ui, { user = null, ...options } = {}) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<BrowserRouter>
<AuthContext.Provider value={{ user, isLoading: false }}>
{ui}
</AuthContext.Provider>
</BrowserRouter>
</QueryClientProvider>,
options
);
}
// Test
test('shows dashboard for authenticated user', () => {
renderWithProviders(<Dashboard />, { user: { id: 1, name: 'Alice', role: 'admin' } });
expect(screen.getByText(/welcome, alice/i)).toBeInTheDocument();
});renderHook renders a hook without needing a UI component. act wraps state updates so React processes them synchronously. result.current gives the current return value. Ideal for testing complex custom hooks that manage state machines, timers, or async data.
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('increments counter', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.increment());
act(() => result.current.increment());
expect(result.current.count).toBe(3);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => result.current.increment());
act(() => result.current.reset());
expect(result.current.count).toBe(10);
});Jest’s toMatchSnapshot serializes the rendered output to a file. On subsequent runs, the current output is compared against the stored snapshot. A mismatch means either a bug (fix the code) or an intentional change (update the snapshot with --updateSnapshot). Use sparingly — snapshots bloat easily and become noise.
import { render } from '@testing-library/react';
test('Button renders correctly for each variant', () => {
const { asFragment } = render(
<>
<Button variant="primary">Save</Button>
<Button variant="danger">Delete</Button>
<Button variant="ghost">Cancel</Button>
</>
);
expect(asFragment()).toMatchSnapshot(); // saves/compares HTML snapshot
});
// Inline snapshot — diff visible in code review
test('renders badge count', () => {
const { asFragment } = render(<Badge count={5} />);
expect(asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<span class="badge">5</span>
</DocumentFragment>
`);
});jest-axe runs the axe accessibility engine against the rendered HTML. It catches common WCAG violations: missing alt text, form labels not linked to inputs, insufficient color contrast, incorrect ARIA roles. Integrate into your test suite to prevent accessibility regressions.
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('LoginForm has no accessibility violations', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Common violations axe catches:
// - <img> without alt attribute
// - <input> without associated <label>
// - <button> with no accessible name
// - heading hierarchy skipped (h1 → h3)
// - interactive elements not keyboard-focusablefindBy* queries return a promise that resolves when the element appears (or rejects after timeout). waitFor retries an assertion until it passes. Use these for: elements that appear after a fetch, state updates after click, or animations that complete. Avoid arbitrary setTimeout delays in tests.
test('shows success message after form submission', async () => {
const user = userEvent.setup();
render(<ContactForm />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.click(screen.getByRole('button', { name: /submit/i }));
// findByText waits up to 1000ms for element to appear
const success = await screen.findByText(/message sent/i);
expect(success).toBeInTheDocument();
// waitFor — retry assertion until it passes
await waitFor(() => {
expect(screen.queryByRole('button', { name: /submit/i })).toBeDisabled();
});
});RTL tests components in isolation with mocked APIs. E2E tests (Playwright, Cypress) run in a real browser against a real (or staging) backend — they catch integration issues RTL can’t: network errors, SSR hydration bugs, browser-specific rendering, OAuth flows. Run RTL in every PR; run E2E on main branch or pre-release.
// Playwright — tests/auth.spec.ts
import { test, expect } from '@playwright/test';
test('user can log in and see dashboard', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('alice@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome, Alice')).toBeVisible();
});
test('protected route redirects to login when not authenticated', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL('/login?from=%2Fdashboard');
});Server Components (RSC) run only on the server — they can directly access databases, file systems, and secrets, and their code is never sent to the browser. Client components are marked 'use client' and handle interactivity. RSC enables zero-bundle-size data fetching components.
// app/orders/page.tsx — Server Component (no 'use client')
// Runs on server — DB access, no useEffect, no useState
import { db } from '@/lib/db';
export default async function OrdersPage() {
const orders = await db.order.findMany({ orderBy: { createdAt: 'desc' } });
return (
<div>
<h1>Orders</h1>
{orders.map(o => <OrderCard key={o.id} order={o} />)}
</div>
);
}
// components/AddToCart.tsx — Client Component (needs interactivity)
'use client';
export function AddToCart({ productId }) {
const [added, setAdded] = React.useState(false);
return <button onClick={() => setAdded(true)}>{added ? '✓ Added' : 'Add to Cart'}</button>;
}App Router uses React Server Components by default, with file-based layouts nested via layout.tsx files. Pages Router uses getServerSideProps/getStaticProps with only client components. App Router enables streaming SSR, nested Suspense, and Server Actions for form handling without API routes.
app/
layout.tsx ← root layout (wraps all pages)
page.tsx ← /
dashboard/
layout.tsx ← dashboard shell (sidebar/nav — persistent)
page.tsx ← /dashboard
orders/
page.tsx ← /dashboard/orders
[id]/
page.tsx ← /dashboard/orders/:id
loading.tsx ← Suspense fallback for this segment
error.tsx ← Error Boundary for this segment
not-found.tsx ← 404 within this segmentServer Actions are async functions marked 'use server' that run on the server but are callable from client components. Forms can use them as the action prop — data is serialized and sent to the server via a POST without a manual API route. Works with progressive enhancement (no JS required for basic functionality).
// app/actions/createOrder.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createOrder(formData: FormData) {
const product = formData.get('product') as string;
const qty = Number(formData.get('quantity'));
const order = await db.order.create({ data: { product, qty, userId: await getUserId() } });
revalidatePath('/dashboard/orders'); // invalidate cached page
return { success: true, orderId: order.id };
}
// app/orders/new/page.tsx — Client Component
'use client';
import { createOrder } from '@/app/actions/createOrder';
import { useFormState } from 'react-dom';
export default function NewOrderPage() {
const [state, action] = useFormState(createOrder, null);
return (
<form action={action}>
<input name="product" />
<input name="quantity" type="number" />
<button>Create Order</button>
{state?.success && <p>Order #{state.orderId} created!</p>}
</form>
);
}TypeScript catches prop type mismatches at compile time, provides IDE autocomplete for component APIs, and documents intent in the signature. Key patterns: type component props as interfaces, type useState generics explicitly when inference fails, use React.FC sparingly (prefer plain functions), type event handlers precisely.
interface TableProps<T> {
data: T[];
columns: { key: keyof T; label: string; render?: (val: T[keyof T]) => React.ReactNode }[];
onRowClick: (item: T) => void;
}
function Table<T extends { id: string | number }>({ data, columns, onRowClick }: TableProps<T>) {
return (
<table>
<thead><tr>{columns.map(c => <th key={String(c.key)}>{c.label}</th>)}</tr></thead>
<tbody>{data.map(row => (
<tr key={row.id} onClick={() => onRowClick(row)}>
{columns.map(c => <td key={String(c.key)}>{c.render ? c.render(row[c.key]) : String(row[c.key])}</td>)}
</tr>
))}</tbody>
</table>
);
}
// Typed event handlers
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => setValue(e.target.value);CRA bundles the entire app with webpack before serving — cold start takes 20–60 seconds for large apps. Vite serves files as native ES modules, letting the browser resolve imports directly. Only files that are actually requested are transformed. Cold start is under 1 second regardless of app size. HMR updates in milliseconds.
# Scaffold new React + Vite project
npm create vite@latest my-app -- --template react-ts
# vite.config.ts — minimal config
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { port: 3000, open: true },
build: { target: 'es2020', sourcemap: true },
resolve: { alias: { '@': '/src' } } // absolute imports: import { Button } from '@/components'
});With streaming SSR (Next.js App Router, Remix), the server sends HTML in chunks as each Suspense boundary resolves. The browser receives and displays the shell immediately, then progressively receives and hydrates each suspended section. Users see content faster than waiting for a full SSR page render.
// Next.js App Router — streaming SSR with Suspense
export default function ProductPage({ params }) {
return (
<div>
{/* Shell renders immediately */}
<Header />
{/* Streams in when product data is ready */}
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails id={params.id} />
</Suspense>
{/* Streams in when reviews data is ready (independent) */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={params.id} />
</Suspense>
</div>
);
}
// Each async server component suspends independently
async function ProductDetails({ id }) {
const product = await db.product.findUnique({ where: { id } }); // suspends
return <div>{product.name}</div>;
}Tailwind CSS provides atomic utility classes — no CSS files needed per component. Classes are purged in production (only used classes ship). Combine with clsx or cva (Class Variance Authority) for conditional and variant-based class composition in React components.
import { cva } from 'class-variance-authority';
import clsx from 'clsx';
// cva — type-safe component variant system
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none',
{
variants: {
variant: {
primary: 'bg-violet-600 text-white hover:bg-violet-700',
outline: 'border border-violet-600 text-violet-600 hover:bg-violet-50',
ghost: 'text-violet-600 hover:bg-violet-50'
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base'
}
},
defaultVariants: { variant: 'primary', size: 'md' }
}
);
function Button({ variant, size, className, ...rest }) {
return <button className={clsx(buttonVariants({ variant, size }), className)} {...rest} />;
}Storybook renders each component in isolation with different prop combinations (“stories”). It serves as living documentation, catches visual regressions with snapshot/visual testing, and lets designers review components without running the full app. Addons enable a11y checks, interaction testing, and responsive previews.
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'UI/Button',
component: Button,
argTypes: { variant: { control: 'select', options: ['primary','outline','ghost'] } }
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = { args: { variant: 'primary', children: 'Click me' } };
export const Loading: Story = { args: { loading: true, children: 'Saving…' } };
export const Destructive: Story = {
args: { variant: 'outline', children: 'Delete account' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
await expect(canvas.getByText('Delete account')).toBeVisible();
}
};React Native components (View, Text, Pressable) are different from HTML elements — UI code doesn’t share. But custom hooks (business logic, data fetching, state machines) are pure JavaScript with no DOM dependency — they run identically on both platforms. This is the core of a monorepo strategy with shared logic packages.
// packages/shared/hooks/useCartState.ts — shared across web and mobile
export function useCartState() {
const [items, dispatch] = React.useReducer(cartReducer, []);
const addItem = (item) => dispatch({ type: 'ADD', payload: item });
const removeItem = (id) => dispatch({ type: 'REMOVE', payload: id });
const total = items.reduce((s, i) => s + i.price * i.qty, 0);
return { items, total, addItem, removeItem };
}
// apps/web/components/Cart.tsx
export function Cart() {
const { items, total, removeItem } = useCartState();
return <ul>{items.map(i => <li key={i.id}>{i.name} <button onClick={() => removeItem(i.id)}>✕</button></li>)}</ul>;
}
// apps/mobile/screens/CartScreen.tsx
export function CartScreen() {
const { items, total, removeItem } = useCartState(); // same hook
return <FlatList data={items} renderItem={({ item }) => <View><Text>{item.name}</Text></View>} />;
}Micro-frontends let different teams deploy independent React apps that are composed at runtime. Module Federation (webpack 5, Vite plugin) exposes components from one app and imports them in another at runtime — no build-time coupling. Each team ships independently; the shell app composes them. Useful for large organizations with separate deployment pipelines.
// checkout-app/vite.config.ts — exposes CheckoutWidget
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [federation({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: { './CheckoutWidget': './src/CheckoutWidget' },
shared: ['react', 'react-dom'] // shared singletons — avoid duplicate React
})]
});
// shell-app — consumes CheckoutWidget at runtime
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [federation({
remotes: { checkout: 'http://checkout.myapp.com/assets/remoteEntry.js' },
shared: ['react', 'react-dom']
})]
});
// shell-app component
const CheckoutWidget = React.lazy(() => import('checkout/CheckoutWidget'));
<Suspense fallback={<Spinner />}><CheckoutWidget /></Suspense>