React remains the most widely adopted library for building interactive user interfaces. This guide goes beyond the basics — it covers the hooks you use daily, the ones you reach for in complex scenarios, and the patterns that separate junior from senior React development. Each section includes realistic code examples, explanations of why each pattern matters, and links to authoritative resources.
Functional Components as the Unit of Composition
Functional components are JavaScript functions that return JSX. They replaced class components as the standard because they are simpler (no this binding), composable, and compatible with hooks.
A basic functional component with local state:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]);
if (!user) return <p>Loading user…</p>;
return <h2>{user.name}</h2>;
}
Components that return JSX from a single function are easy to reason about — they take props and state in, and return UI. Break complex views into smaller components that each handle one concern.
JSX — Syntax Born from JavaScript
JSX is a syntax extension that compiles to React.createElement calls. It lets you write markup alongside logic.
const userName = 'Ada';
const greeting = <h1>Welcome, {userName}</h1>;
Key JSX rules:
- Embed expressions with
{ }. - Use
classNameinstead ofclass. - Attributes use camelCase:
onClick,readOnly,tabIndex. - Self-closing tags required for elements with no children:
<img />.
JSX is compiled at build time — browsers never see it directly. This enables tooling like linting, formatting, and type-checking with TypeScript.
Essential Hooks — useState, useEffect, useContext
useState
Returns a state variable and a setter. Use the functional update form when the new value depends on the previous one.
const [count, setCount] = useState(0);
setCount(prev => prev + 1);
Derive values when possible instead of storing redundant state. Split unrelated concerns into separate useState calls to prevent accidental coupling.
useEffect
Runs side effects after render — data fetching, subscriptions, DOM manipulation.
useEffect(() => {
fetchData(query).then(setResults);
}, [query]);
Lifecycle equivalents:
| Intention | Dependency Array | Example |
|---|---|---|
| Mount (once) | [] |
Subscribe to a channel, fetch initial data |
| Update (on change) | [dep1, dep2] |
Fetch when query changes |
| Unmount (cleanup) | Return function | Unsubscribe, clear timers |
useEffect(() => {
const connection = createWebSocketConnection();
connection.onMessage(setMessages);
return () => connection.disconnect();
}, []);
Missing dependencies produce stale closures. Including objects or functions as dependencies without stabilizing them (via useCallback or useMemo) may cause infinite loops.
useContext
Reads a context value without prop drilling.
const AuthContext = createContext(null);
function UserAvatar() {
const user = useContext(AuthContext);
return <img src={user.avatar} alt={user.name} />;
}
Use context for cross-cutting concerns (theme, locale, auth). Avoid it for rapidly changing data — every consumer re-renders when the context value changes, which can cause performance issues in deep trees.
useRef — Beyond DOM References
useRef serves two distinct purposes: holding a reference to a DOM node and persisting a mutable value across renders without causing re-renders.
DOM Refs
function VideoPlayer() {
const videoRef = useRef(null);
function handlePlay() {
videoRef.current?.play();
}
return (
<>
<video ref={videoRef} src="intro.mp4" />
<button onClick={handlePlay}>Play</button>
</>
);
}
Mutable Values (No Re-render)
Unlike state, changing ref.current does not trigger a re-render. Use this for timers, animation frames, or any value that must persist across renders but does not affect the UI.
function Stopwatch() {
const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef(null);
function start() {
intervalRef.current = setInterval(() => {
setElapsed(prev => prev + 1);
}, 1000);
}
function stop() {
clearInterval(intervalRef.current);
}
// ...
}
forwardRef — Passing Refs to Children
Wrap a component with forwardRef so a parent can access its DOM node directly.
const FancyInput = forwardRef(function FancyInput(props, ref) {
return <input ref={ref} className="fancy" {...props} />;
});
// Parent
function Form() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <FancyInput ref={inputRef} placeholder="Name" />;
}
Callback Refs
When you need fine-grained control over when a ref is set or cleared — for example, measuring a node’s size after it mounts — use a callback ref.
function MeasureHeight() {
const [height, setHeight] = useState(0);
const measuredRef = useCallback(node => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
}
}, []);
return (
<>
<p ref={measuredRef}>This element's height is {height}px.</p>
</>
);
}
Callback refs are called both when the node mounts and when it unmounts (with null), making them reliable for lifecycle-aware measurements.
useMemo and useCallback — Memoization With Purpose
Both hooks cache values between renders. The key difference: useMemo caches a computed value; useCallback caches a function reference.
When to Use useMemo
Apply useMemo to expensive computations you do not want to repeat on every render.
function ProductList({ products, filters }) {
const visibleProducts = useMemo(() => {
return products
.filter(p => p.price >= filters.minPrice)
.sort((a, b) => a.price - b.price);
}, [products, filters]);
// ...
}
Without useMemo, filtering and sorting would re-run on every render even when products and filters have not changed.
When to Use useCallback
Pass useCallback when you pass a function to a memoized child component. Without it, the child receives a new function reference on every render and React.memo cannot skip rendering.
function ProductPage({ productId }) {
const handleAddToCart = useCallback(() => {
addItemToCart(productId);
}, [productId]);
return <AddToCartButton onAdd={handleAddToCart} />;
}
const AddToCartButton = React.memo(function AddToCartButton({ onAdd }) {
return <button onClick={onAdd}>Add to Cart</button>;
});
When NOT to Use Them
| Scenario | Why Avoid |
|---|---|
Trivial calculations (e.g. a + b) |
The overhead of the memoization check exceeds recomputation cost |
| Functions passed to raw DOM elements | React does not memoize DOM element prop comparisons |
| Components that always re-render anyway | useCallback adds complexity but changes nothing |
| Before profiling shows a bottleneck | Premature optimization bloats code without measurable benefit |
The React Compiler (React 19+) automates many of these decisions at build time. Until you adopt it, measure with React DevTools Profiler before wrapping code in useMemo or useCallback.
useReducer — Predictable State Transitions
When state logic involves multiple sub-values or depends on complex transitions, useReducer provides a more structured alternative to useState.
const initialState = { items: [], total: 0 };
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
return {
...state,
items: [...state.items, action.payload],
total: state.total + action.payload.price,
};
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(item => item.id !== action.payload.id),
total: state.total - action.payload.price,
};
case 'CLEAR':
return initialState;
default:
return state;
}
}
function ShoppingCart() {
const [cart, dispatch] = useReducer(cartReducer, initialState);
return (
<>
<p>Total: ${cart.total.toFixed(2)}</p>
<button onClick={() => dispatch({ type: 'CLEAR' })}>Clear Cart</button>
{cart.items.map(item => (
<CartItem key={item.id} item={item}
onRemove={() => dispatch({ type: 'REMOVE_ITEM', payload: item })} />
))}
</>
);
}
useReducer shines when:
- Next state depends on previous state in non-trivial ways.
- Multiple state fields must update together.
- The logic is easier to express as a reducer (especially if you already use Redux or similar).
For simple toggles or single-value state, useState remains the clearer choice.
Custom Hooks — Compose, Extract, Reuse
Custom hooks are functions that call other hooks. They let you extract stateful logic from components so it can be tested and reused independently.
Hook 1: useDocumentTitle — Synchronizing With External State
function useDocumentTitle(title) {
useEffect(() => {
document.title = title;
return () => { document.title = 'Calmops'; };
}, [title]);
}
Usage:
function SettingsPage() {
useDocumentTitle('Settings — Calmops');
// ...
}
Hook 2: useLocalStorage — Persisting State Across Sessions
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(value => {
setStoredValue(prev => {
const nextValue = typeof value === 'function' ? value(prev) : value;
window.localStorage.setItem(key, JSON.stringify(nextValue));
return nextValue;
});
}, [key]);
return [storedValue, setValue];
}
This hook manages serialization, error handling, and the synchronization between React state and localStorage automatically.
Hook 3: useDebounce — Throttling Rapid Input Changes
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
Usage — only search after the user stops typing:
function SearchPage() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 400);
const results = useSearch(debouncedQuery);
return (
<input value={query} onChange={e => setQuery(e.target.value)} />
);
}
Composing Hooks Together
Hooks can call other hooks. Compose small custom hooks into richer abstractions:
function useUserPreferences(userId) {
const [prefs, setPrefs] = useLocalStorage(`prefs-${userId}`, {
theme: 'light',
fontSize: 16,
});
useDocumentTitle(`Settings — ${userId}`);
return [prefs, setPrefs];
}
Keep custom hooks focused on a single concern. If a hook handles data fetching, form state, and keyboard shortcuts, split it into three smaller hooks.
Performance Optimization Patterns
React.memo — Preventing Unnecessary Child Renders
Wrap a component with React.memo to skip re-rendering when its props have not changed (shallow comparison).
const ExpenseRow = React.memo(function ExpenseRow({ label, amount }) {
return (
<tr>
<td>{label}</td>
<td>${amount.toFixed(2)}</td>
</tr>
);
});
Use React.memo on leaf components that render frequently with the same props. Do not wrap every component — the shallow comparison itself has a cost.
useId — Accessible Unique Identifiers
useId generates stable, unique IDs for accessibility attributes, avoiding hydration mismatches in server-rendered apps.
function SearchField() {
const id = useId();
return (
<>
<label htmlFor={id}>Search</label>
<input id={id} type="search" />
</>
);
}
lazy and Suspense — Code Splitting
Split your bundle so large components load only when needed.
const AdminDashboard = lazy(() => import('./AdminDashboard'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<AdminDashboard />
</Suspense>
);
}
The lazy function tells React to load the component’s module on first render. Suspense shows a fallback while the module loads. Use this for routes, modals, and heavy third-party components.
Form Handling — Controlled, Uncontrolled, and React 19 Hooks
Controlled Components
React controls the input value via state. Every keystroke updates state, which re-renders the input.
function NewsletterForm() {
const [email, setEmail] = useState('');
function handleSubmit(e) {
e.preventDefault();
subscribeToNewsletter(email);
}
return (
<form onSubmit={handleSubmit}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} />
<button type="submit">Subscribe</button>
</form>
);
}
Uncontrolled Components (useRef)
The DOM manages the input value. Read it only when needed.
function UncontrolledForm() {
const nameRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
alert(`Hello, ${nameRef.current.value}`);
}
return (
<form onSubmit={handleSubmit}>
<input ref={nameRef} defaultValue="Guest" />
<button type="submit">Submit</button>
</form>
);
}
Uncontrolled inputs reduce re-renders and are useful for large forms where you only need values at submission time.
useFormStatus (React 19)
A child of a <form> can read the form’s submission status without prop drilling.
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>;
}
function ProfileForm() {
return (
<form action={updateProfile}>
<input name="username" />
<SubmitButton />
</form>
);
}
useOptimistic (React 19)
Update the UI immediately while the server request is in flight. Fall back to the real state if the request fails.
function LikeButton({ postId, initialLikes }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state, newCount) => newCount
);
async function handleLike() {
addOptimisticLike(optimisticLikes + 1);
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
}
return <button onClick={handleLike}>{optimisticLikes} ❤️</button>;
}
This pattern makes interactions feel instant while keeping eventual consistency with the server.
TypeScript Integration — Typing Every Layer
Typing Props
interface ButtonProps {
label: string;
variant?: 'primary' | 'secondary';
disabled?: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
children?: React.ReactNode;
}
function Button({ label, variant = 'primary', disabled, onClick, children }: ButtonProps) {
return (
<button className={`btn btn--${variant}`} disabled={disabled} onClick={onClick}>
{label} {children}
</button>
);
}
Typing Hooks
TypeScript infers most hook types, but you can provide generics for union states or complex reducers.
// useState with union type
const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle');
// useRef for DOM element
const inputRef = useRef<HTMLInputElement>(null);
// useRef for mutable value
const timerRef = useRef<number | null>(null);
// useReducer with typed actions
type CartAction =
| { type: 'ADD_ITEM'; payload: Product }
| { type: 'REMOVE_ITEM'; payload: { id: string } }
| { type: 'CLEAR' };
function cartReducer(state: CartState, action: CartAction): CartState {
// ...
}
Typing Events
function InputField() {
const [value, setValue] = useState('');
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setValue(event.target.value);
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
}
return (
<form onSubmit={handleSubmit}>
<input value={value} onChange={handleChange} />
</form>
);
}
Children Patterns
Since React 18, React.FC no longer includes children automatically. Declare it explicitly:
// Option A: manual
interface CardProps {
title: string;
children: React.ReactNode;
}
// Option B: PropsWithChildren utility
import { PropsWithChildren } from 'react';
interface CardProps {
title: string;
}
function Card({ title, children }: PropsWithChildren<CardProps>) {
return (
<div className="card">
<h3>{title}</h3>
{children}
</div>
);
}
For components that accept only specific child types (e.g. Tab children inside a Tabs component), use React.ReactElement with the expected component type.
Testing Patterns — React Testing Library
React Testing Library encourages testing components the way a user interacts with them: through the DOM, not component internals.
Rendering and Querying
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
test('increments count on button click', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
await user.click(button);
expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});
Query Priority
| Query | When to Use |
|---|---|
getByRole |
Accessible elements (buttons, headings, links) — best choice |
getByLabelText |
Form inputs with <label> associations |
getByPlaceholderText |
Inputs with placeholder text |
getByText |
Non-interactive text elements (paragraphs, divs) |
getByTestId |
Escape hatch — use only when no other query works |
Testing Async Behavior
test('loads and displays user data', async () => {
render(<UserProfile userId="42" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
expect(await screen.findByRole('heading', { name: /ada/i })).toBeInTheDocument();
});
Use findBy* queries (which return a promise) for elements that appear after async operations. Use waitFor for arbitrary assertion retries.
Custom Hook Testing
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
test('increments counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
What Not to Test
- Do not test implementation details (internal state, prop names).
- Do not use
container.querySelector— it couples tests to DOM structure. - Do not test that a function was called unless it produces observable DOM changes.
- Prefer
userEventoverfireEvent— it dispatches the full chain of events a real browser would.
Resources
- React Documentation — react.dev
- React 19 Hooks — useFormStatus
- React 19 Hooks — useOptimistic
- React Testing Library — testing-library.com
- React TypeScript Cheatsheet
- Kent C. Dodds — Common Mistakes with React Testing Library
- Josh W. Comeau — Understanding useMemo and useCallback
- Patterns.dev — React Design Patterns
Comments