JSX & Components
Function Components
React components are JavaScript functions that return JSX. Component names must start with a capital letter (lowercase = HTML tags). Props are passed as attributes and destructured in the parameter. JSX is syntactic sugar for React.createElement(). Always return a single root element (or use Fragments). Components must be pure — same props = same output.
// Basic function component
function Welcome({ name }) {
return <h1>Hello, {name}!</h1>;
}
// Arrow function component
const Greeting = ({ name = 'Guest' }) => (
<p>Welcome, {name}!</p>
);
// Composing components
function App() {
return (
<div>
<Welcome name="Alice" />
<Welcome name="Bob" />
<Greeting />
</div>
);
}JSX Expressions
JSX allows embedding JavaScript expressions in curly braces {}. You can put variables, function calls, ternary operators, and any expression that returns a value. Statements (if, for, switch) are not allowed directly — use ternary or IIFE. Boolean values (true, false), null, and undefined render as nothing. Numbers and strings render as text. Objects are not valid React children.
function Expression({ user, items }) {
const fullName = user.first + ' ' + user.last;
const itemCount = items.length;
return (
<div>
{/* Expressions in curly braces */}
<h1>{fullName}</h1>
<p>{itemCount} items</p>
{/* Conditional */}
<p>{itemCount > 0 ? 'In stock' : 'Sold out'}</p>
{/* Method calls */}
<p>{fullName.toUpperCase()}</p>
{/* Numbers and booleans render as nothing */}
<p>{false}{null}{undefined}</p>
</div>
);
}Fragments & Lists in JSX
Fragments (<>...</>) group multiple elements without adding extra DOM nodes — cleaner than wrapping in a <div>. Use <Fragment key={...}> when you need to pass a key. Arrays of JSX elements need unique key props. While array index as key works for static lists, use stable IDs for dynamic lists to prevent rendering bugs. Fragments improve performance by reducing unnecessary wrapper elements.
// Fragment: group without extra DOM node
function App() {
return (
<>
<header>Header</header>
<main>Content</main>
<footer>Footer</footer>
</>
);
}
// Array of elements (needs keys)
function List() {
const fruits = ['Apple', 'Banana', 'Cherry'];
return (
<ul>
{fruits.map((fruit, i) => (
<li key={i}>{fruit}</li>
))}
</ul>
);
}Conditional Rendering
React offers multiple conditional rendering patterns. Early returns for if/else logic. Ternary (cond ? A : B) for either/or. Logical AND (cond && <Component/>) for show/hide. IIFE for complex branching. Avoid embedding complex logic in JSX — extract to variables or helper functions. For switch statements, use a lookup object or extract to a separate function. Falsy values (0, '') render, so use ternary instead of && for numbers.
function Greeting({ isLoggedIn, user }) {
// 1. If/else (use early return)
if (!isLoggedIn) return <Login />;
// 2. Ternary operator
return (
<div>
{user ? <Dashboard user={user} /> : <Loading />}
{/* 3. Logical AND (render if truthy) */}
{user.isAdmin && <AdminPanel />}
{/* 4. IIFE for complex logic */}
{(() => {
if (user.role === 'admin') return <Admin />;
if (user.role === 'mod') return <Mod />;
return <User />;
})()}
</div>
);
}Children & Render Props
The children prop contains elements between opening and closing tags — essential for composable components (cards, modals, layouts). Render props pass a function as a prop that receives data and returns JSX — an alternative to HOCs and hooks for sharing logic. While render props are less common with hooks, they're still useful for component injection patterns. children is a special prop that doesn't need to be explicitly passed.
// children prop: content between tags
function Card({ title, children }) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
);
}
// Usage
<Card title="Profile">
<p>Name: Alice</p>
<p>Age: 30</p>
</Card>
// Render prop pattern
function DataProvider({ render }) {
const data = fetchData();
return <div>{render(data)}</div>;
}Props
Passing Props
Props are read-only data passed from parent to child. They can be any JavaScript value: strings, numbers, booleans, arrays, objects, or functions. String values use quotes (name='Alice'), all other values use curly braces (age={30}). Functions as props enable child-to-parent communication (callbacks). Props flow downward — children cannot modify props. For two-way data binding, lift state to the common parent.
// Parent passes props to child
function App() {
return (
<User
name="Alice"
age={30}
isActive={true}
tags={['admin', 'dev']}
onClick={() => console.log('clicked')}
/>
);
}
// Child receives props
function User({ name, age, isActive, tags, onClick }) {
return (
<div onClick={onClick}>
<h1>{name}</h1>
<p>Age: {age}</p>
<p>Status: {isActive ? 'Active' : 'Inactive'}</p>
</div>
);
}Default & Optional Props
Default prop values are set via destructuring (param = defaultValue). If a prop is not passed, it's undefined. Use short-circuit (bio && <p>) or ternary to conditionally render optional props. PropTypes (legacy) or TypeScript interfaces can validate prop types at development time. defaultProps (class components) is deprecated for function components — use destructuring defaults instead.
// Default values via destructuring
function Button({ color = 'blue', size = 'md', children }) {
return (
<button className={'btn btn-' + color + ' btn-' + size}>
{children}
</button>
);
}
// Optional props (undefined if not passed)
function Profile({ name, bio }) {
return (
<div>
<h1>{name}</h1>
{bio && <p>{bio}</p>}
</div>
);
}
// Usage
<Button>Click</Button> {/* color='blue', size='md' */}
<Profile name="Alice" /> {/* bio is undefined */}Spread & Rest Props
The spread operator (...props) passes all props to a child element — useful for wrapper components (HOCs, styled components). The rest operator collects remaining props after destructuring specific ones. This pattern is common in design systems where a wrapper component forwards unknown props to a DOM element. Be careful: spreading can override explicit attributes — order matters ({...props} className='x' vs className='x' {...props}).
// Spread: pass all props to child
function Input(props) {
return <input {...props} className="input" />;
}
// Usage
<Input type="text" placeholder="Name" value="Alice" />
// Rest: collect remaining props
function Button({ label, ...rest }) {
return <button {...rest}>{label}</button>;
}
// Selective spreading
function Card({ title, children, ...divProps }) {
return (
<div {...divProps}>
<h2>{title}</h2>
{children}
</div>
);
}Prop Types & TypeScript
TypeScript interfaces provide compile-time type checking for props — the recommended approach for new React projects. Optional props use ? (isActive?: boolean). PropTypes provide runtime validation (only in development) and are useful for JavaScript projects without TypeScript. isRequired ensures the prop is provided. TypeScript catches type errors before runtime, making it superior for large codebases.
// TypeScript interface (recommended)
interface UserProps {
name: string;
age: number;
isActive?: boolean; // optional
onClick: (id: number) => void;
}
function User({ name, age, isActive = true, onClick }: UserProps) {
return <div onClick={() => onClick(1)}>{name}, {age}</div>;
}
// PropTypes (runtime checking, legacy)
import PropTypes from 'prop-types';
User.propTypes = {
name: PropTypes.string.isRequired,
age: PropTypes.number,
isActive: PropTypes.bool,
};Prop Drilling & Context
Prop drilling occurs when props pass through multiple component layers that don't use them. For 2-3 levels, it's acceptable. For deeper trees, use Context API, state management libraries (Redux, Zustand), or component composition. Composition (passing components as props or children) often solves drilling more elegantly than Context. Ask: does every intermediate component need this data? If not, reconsider your component structure.
// Prop drilling: passing through multiple levels
function App() {
const [user, setUser] = useState(null);
return <Layout user={user} />;
}
function Layout({ user }) {
return <Sidebar user={user} />;
}
function Sidebar({ user }) {
return <UserInfo user={user} />;
}
// Solution: Context API (see State Management section)
// Avoid drilling more than 2-3 levelsuseState & State
Basic useState
useState is the fundamental hook for adding state to function components. It returns an array: [currentValue, setterFunction]. The initial value can be any type. Calling the setter triggers a re-render with the new value. State updates are asynchronous — the value doesn't change immediately after calling setCount. Each component instance has its own independent state. The setter is stable (same reference across renders).
import { useState } from 'react';
function Counter() {
// [currentValue, setterFunction] = useState(initialValue)
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}Functional Updates
When the new state depends on the previous state, use a functional update: setCount(prev => prev + 1). This guarantees you're working with the latest state, even if multiple updates are batched. Without functional updates, rapid successive calls may use stale state. React 18 batches state updates automatically (even in promises and timeouts), so functional updates are essential for correctness.
function Counter() {
const [count, setCount] = useState(0);
// BAD: may not work correctly with rapid updates
const incrementBad = () => setCount(count + 1);
// GOOD: functional update uses previous state
const incrementGood = () => setCount(prev => prev + 1);
// Batch updates
const addThree = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
};
return <button onClick={addThree}>Count: {count}</button>;
}State with Objects & Arrays
Never mutate state directly — always create a new object/array. For objects, use the spread operator to copy existing properties: {...prev, [field]: value}. For arrays, use spread to add ([...prev, newItem]), filter to remove, and map to update. React compares references to detect changes — mutated objects have the same reference, so React won't re-render. This is the #1 source of React bugs for beginners.
function Form() {
const [form, setForm] = useState({ name: '', email: '', age: 0 });
// BAD: mutates state directly
// form.name = 'Alice'; setForm(form);
// GOOD: spread to create new object
const updateField = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }));
};
return (
<input
value={form.name}
onChange={e => updateField('name', e.target.value)}
/>
);
}
// Array state
function TodoList() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => setTodos(prev => [...prev, { id: Date.now(), text }]);
const removeTodo = (id) => setTodos(prev => prev.filter(t => t.id !== id));
}Lazy Initial State
If the initial state requires an expensive computation, pass a function to useState (lazy initialization). The function runs only on the first render, not on every re-render. This is important for parsing localStorage, fetching from IndexedDB, or any CPU-intensive setup. The function form: useState(() => initialValue). For simple values (numbers, strings), just pass the value directly — lazy init is unnecessary.
import { useState } from 'react';
function ExpensiveInit() {
// BAD: runs on every render (even though result is ignored)
const [data, setData] = useState(computeExpensiveValue());
// GOOD: lazy initialization — function runs only once
const [data2, setData2] = useState(() => computeExpensiveValue());
// Reading from localStorage
const [user, setUser] = useState(() => {
const saved = localStorage.getItem('user');
return saved ? JSON.parse(saved) : null;
});
return <div>{data2}</div>;
}Multiple State Variables
Use multiple useState calls for independent values rather than one big object. This makes updates simpler (no need to spread) and prevents unnecessary re-renders. Group related values in a single state object (e.g., form fields). For complex state logic with multiple sub-values, consider useReducer instead. Rule of thumb: if state updates are independent, use separate useState; if they're related/interdependent, use useReducer or a single object.
function LoginForm() {
// Multiple independent state variables
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
setIsSubmitting(true);
// ... validation and submission
};
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" value={password}
onChange={e => setPassword(e.target.value)} />
<button disabled={isSubmitting}>Submit</button>
</form>
);
}useEffect & Side Effects
Basic useEffect
useEffect performs side effects after render. The effect function runs after the component paints. The cleanup function (returned) runs before the next effect and on unmount — essential for clearing timers, subscriptions, and listeners. The dependency array controls when the effect re-runs: [] = once on mount, [dep] = when dep changes, no array = every render. Always clean up to prevent memory leaks.
import { useState, useEffect } from 'react';
function Timer() {
const [seconds, setSeconds] = useState(0);
// Runs after every render
useEffect(() => {
const interval = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
// Cleanup function runs before next effect or unmount
return () => clearInterval(interval);
}, []); // empty array = run once on mount
return <p>Seconds: {seconds}</p>;
}Dependency Array
The dependency array is critical for useEffect behavior. Empty array [] = mount only (like componentDidMount). With dependencies [a, b] = runs on mount and when a or b changes. No array = every render (rarely what you want). Missing dependencies cause stale closures. Including unnecessary dependencies causes excessive re-runs. Use the exhaustive-deps ESLint rule to catch mistakes. Every value from the component scope used in the effect should be in the deps.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// Runs once on mount (empty deps)
useEffect(() => {
console.log('Component mounted');
}, []);
// Runs when userId changes
useEffect(() => {
fetch('/api/users/' + userId)
.then(r => r.json())
.then(setUser);
}, [userId]); // re-run when userId changes
// Runs on every render (no deps) - rarely needed
useEffect(() => {
console.log('Every render');
});
return <div>{user?.name}</div>;
}Cleanup & Subscriptions
Cleanup is essential for subscriptions, event listeners, timers, and WebSocket connections. Without cleanup, you get memory leaks and duplicate handlers. The cleanup function runs: (1) before the next effect re-run, (2) on component unmount. For WebSocket/event listeners, always remove them in cleanup. For state that depends on the effect, reset it in cleanup to avoid showing stale data from a previous roomId.
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const ws = new WebSocket('wss://chat.example.com/' + roomId);
ws.onmessage = (event) => {
setMessages(prev => [...prev, JSON.parse(event.data)]);
};
// Cleanup: close connection when roomId changes or unmount
return () => {
ws.close();
setMessages([]); // reset for new room
};
}, [roomId]);
// Window event listener
useEffect(() => {
const handleResize = () => console.log(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <div>{messages.length} messages</div>;
}Fetching Data
Data fetching in useEffect requires a cancellation flag to prevent setting state after unmount (causes React warnings). The 'cancelled' flag ensures setUsers/setError/setLoading only run if the component is still mounted. For production apps, consider using a data-fetching library (React Query, SWR) which handles caching, deduplication, and race conditions automatically. The empty dependency array [] ensures fetching happens once on mount.
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
try {
setLoading(true);
const res = await fetch('/api/users');
const data = await res.json();
if (!cancelled) setUsers(data);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchData();
return () => { cancelled = true; };
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}useLayoutEffect vs useEffect
useEffect runs asynchronously after the browser paints — users may see a brief flash if you're measuring DOM elements. useLayoutEffect runs synchronously after DOM mutations but before paint — preventing visual flicker. Use useLayoutEffect for DOM measurements (getBoundingClientRect, scroll position) that affect layout. Use useEffect for everything else (it doesn't block painting). On the server, useLayoutEffect warns — use useIsomorphicLayoutEffect pattern.
import { useState, useEffect, useLayoutEffect } from 'react';
function Tooltip({ text }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
// useEffect: runs AFTER paint (user may see flash)
useEffect(() => {
const rect = document.getElementById('tip').getBoundingClientRect();
setPosition({ x: rect.x, y: rect.y });
}, [text]);
// useLayoutEffect: runs BEFORE paint (no flash)
useLayoutEffect(() => {
const rect = document.getElementById('tip').getBoundingClientRect();
setPosition({ x: rect.x, y: rect.y });
}, [text]);
return <div id="tip" style={{ left: position.x, top: position.y }}>{text}</div>;
}useRef, useMemo & useCallback
useRef Basics
useRef returns a mutable object { current: value } that persists across renders. Unlike state, changing ref.current does NOT trigger a re-render. Common uses: (1) accessing DOM elements (via ref attribute), (2) storing mutable values that don't affect rendering (timers, previous values), (3) storing the latest value for use in callbacks. The ref object has the same identity across renders. Initial value is passed to useRef(initialValue).
import { useRef } from 'react';
function FocusInput() {
// ref to access DOM element
const inputRef = useRef(null);
const focus = () => inputRef.current.focus();
const clear = () => {
inputRef.current.value = '';
inputRef.current.focus();
};
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focus}>Focus</button>
<button onClick={clear}>Clear</button>
</div>
);
}useRef for Mutable Values
useRef stores mutable values that persist across renders without triggering re-renders. This is perfect for: timer IDs, WebSocket references, tracking previous state, and counting renders. Since changing ref.current doesn't cause a re-render, the UI won't update when you change it — use state for values that should affect the UI. The render count pattern (ref.current++) is useful for debugging but shouldn't be used in production logic.
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
const renderCount = useRef(0);
// Track render count (doesn't trigger re-render)
renderCount.current++;
const start = () => {
if (intervalRef.current) return;
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
return (
<div>
<p>{seconds}s (render #{renderCount.current})</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}useMemo
useMemo memoizes (caches) a computed value, recomputing only when dependencies change. Use it for expensive calculations (filtering, sorting, complex math) to avoid re-running on every render. The dependency array works like useEffect. Overuse can hurt performance (memoization has its own overhead) — only memoize genuinely expensive operations. useMemo is also useful for preserving object references to prevent child re-renders.
import { useState, useMemo } from 'react';
function ProductList({ products, filter }) {
const [search, setSearch] = useState('');
// Memoize expensive computation
const filtered = useMemo(() => {
console.log('Filtering...');
return products
.filter(p => p.category === filter)
.filter(p => p.name.includes(search));
}, [products, filter, search]); // recompute only when these change
// Memoize a value
const totalPrice = useMemo(() =>
filtered.reduce((sum, p) => sum + p.price, 0),
[filtered]);
return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
<p>Total: ${totalPrice}</p>
{filtered.map(p => <div key={p.id}>{p.name}</div>)}
</div>
);
}useCallback
useCallback memoizes a function, returning the same reference across renders unless dependencies change. This prevents unnecessary re-renders of memoized child components (wrapped in memo()). Without useCallback, every parent render creates a new function reference, causing memo() children to re-render. Use useCallback when passing callbacks to optimized child components. Like useMemo, don't overuse it — only for functions passed as props to memoized children.
import { useState, useCallback, memo } from 'react';
// Memoized child component
const Button = memo(function Button({ onClick, label }) {
console.log('Button rendered');
return <button onClick={onClick}>{label}</button>;
});
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// Without useCallback: new function every render
// const handleClick = () => setCount(c => c + 1);
// With useCallback: stable function reference
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []); // empty deps = stable forever
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<Button onClick={handleClick} label="Click" />
<p>Count: {count}</p>
</div>
);
}Forwarding Refs
forwardRef allows parent components to pass a ref to a child component's DOM element. useImperativeHandle customizes what the ref exposes — instead of the DOM node, you can expose specific methods (focus, clear, getValue). This is useful for creating reusable input components with imperative APIs. React 19 simplified refs (ref is now a regular prop), but forwardRef is still needed for libraries. Avoid overusing imperative handles — prefer declarative props.
import { useRef, forwardRef, useImperativeHandle } from 'react';
// forwardRef: pass ref to child component
const FancyInput = forwardRef(function FancyInput(props, ref) {
return <input ref={ref} className="fancy" {...props} />;
});
// useImperativeHandle: expose specific methods
const CustomInput = forwardRef(function CustomInput(props, ref) {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
clear: () => { inputRef.current.value = ''; },
getValue: () => inputRef.current.value,
}));
return <input ref={inputRef} />;
});
// Usage
function App() {
const ref = useRef(null);
return (
<>
<CustomInput ref={ref} />
<button onClick={() => ref.current.focus()}>Focus</button>
<button onClick={() => ref.current.clear()}>Clear</button>
</>
);
}useReducer & Context
useReducer Basics
useReducer is an alternative to useState for complex state logic. A reducer is a pure function: (state, action) => newState. Actions describe what happened; the reducer decides how to update state. This pattern makes state transitions predictable and testable. Dispatch is stable (same reference). Always return a new state object (never mutate). The default case should throw an error for unknown actions. Use useReducer when state has multiple sub-values or next state depends on complex logic.
import { useReducer } from 'react';
// Reducer function: (state, action) => newState
function counterReducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
case 'set':
return { count: action.payload };
default:
throw new Error('Unknown action: ' + action.type);
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
<button onClick={() => dispatch({ type: 'set', payload: 10 })}>Set 10</button>
</div>
);
}Complex Reducer
Complex reducers manage multiple related pieces of state. Each action type handles a specific state transition. Always spread the previous state ({...state}) to preserve unrelated fields. For nested updates (like toggling a todo), use map to create a new array with the updated item. Reducers must be pure — no side effects, no API calls. Extract the reducer to a separate file for testability. Consider libraries like Redux Toolkit for very complex state.
const initialState = {
todos: [],
filter: 'all',
loading: false,
};
function todoReducer(state, action) {
switch (action.type) {
case 'add':
return { ...state, todos: [...state.todos, action.todo] };
case 'toggle':
return {
...state,
todos: state.todos.map(t =>
t.id === action.id ? { ...t, done: !t.done } : t
),
};
case 'delete':
return { ...state, todos: state.todos.filter(t => t.id !== action.id) };
case 'set_filter':
return { ...state, filter: action.filter };
case 'set_loading':
return { ...state, loading: action.loading };
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
// dispatch({ type: 'add', todo: { id: 1, text: 'Learn React', done: false } })
}Context API
Context API shares state across the component tree without prop drilling. Create with createContext(defaultValue). Wrap consumers in Provider with a value prop. Consume with useContext(Context). Context value changes trigger re-renders of all consumers. For performance, split contexts (ThemeContext, UserContext) so components only re-render when their specific context changes. The default value is used when no Provider wraps the consumer.
import { createContext, useContext, useState } from 'react';
// 1. Create context with default value
const ThemeContext = createContext('light');
const UserContext = createContext(null);
// 2. Provider component
function App() {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<UserContext.Provider value={{ user, setUser }}>
<Page />
</UserContext.Provider>
</ThemeContext.Provider>
);
}
// 3. Consume context
function Page() {
const { theme } = useContext(ThemeContext);
const { user } = useContext(UserContext);
return <div className={'page theme-' + theme}>Hello {user?.name}</div>;
}Context with Reducer
Combining Context with useReducer creates a lightweight global state management system (mini Redux). The Provider exposes both state and dispatch. A custom hook (useStore) provides error handling if used outside the provider. This pattern is great for medium-sized apps. For very large apps with frequent updates, consider splitting contexts or using Redux/Zustand to avoid re-rendering all consumers on every state change.
import { createContext, useContext, useReducer } from 'react';
// Combine Context + useReducer for global state
const StoreContext = createContext(null);
function StoreProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StoreContext.Provider value={{ state, dispatch }}>
{children}
</StoreContext.Provider>
);
}
// Custom hook for easy consumption
function useStore() {
const context = useContext(StoreContext);
if (!context) throw new Error('useStore must be used within StoreProvider');
return context;
}
// Usage
function Component() {
const { state, dispatch } = useStore();
return <button onClick={() => dispatch({ type: 'action' })}>Click</button>;
}useContext Performance
When context value changes, ALL consumers re-render — even if they only use a small part of the value. To optimize: (1) Split contexts so components only subscribe to what they need. (2) Memoize the context value with useMemo to prevent re-renders when the value hasn't actually changed. (3) Use selectors (use-context-selector library) for fine-grained subscriptions. For high-frequency updates (like mouse position), Context may cause performance issues — consider refs or external stores.
// SPLIT contexts for performance
const ThemeContext = createContext();
const UserContext = createContext();
const CartContext = createContext();
// Each provider manages its own state
function App() {
return (
<ThemeProvider>
<UserProvider>
<CartProvider>
<App />
</CartProvider>
</UserProvider>
</ThemeProvider>
);
}
// Component only re-renders when its context changes
function ThemedButton() {
const { theme } = useContext(ThemeContext);
// Won't re-render when user or cart changes
return <button className={theme}>Button</button>;
}
// Memoize context value to prevent unnecessary re-renders
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}Events & Forms
Event Handling
React events use camelCase (onClick, not onclick). The event object is a SyntheticEvent (wrapper around the native event). e.preventDefault() stops default behavior (form submit, link navigation). e.stopPropagation() prevents event bubbling. To pass parameters to handlers, use arrow functions: onClick={() => handleDelete(id)}. Avoid defining complex handlers inline — extract them for readability. React events are pooled (pre-17), so call e.persist() if you need async access.
function App() {
// Click event
const handleClick = (e) => {
e.preventDefault();
console.log('Button clicked', e.target);
};
// With parameters (use arrow function)
const handleDelete = (id) => {
console.log('Delete item', id);
};
return (
<div>
<button onClick={handleClick}>Click</button>
<button onClick={() => handleDelete(42)}>Delete</button>
<div onMouseEnter={() => console.log('hover')}
onMouseLeave={() => console.log('leave')}>
Hover me
</div>
</div>
);
}Controlled Inputs
Controlled inputs have their value controlled by React state. The value prop sets the input's value, and onChange updates the state. This makes React the 'single source of truth' for form data. Every keystroke triggers a state update and re-render. For complex forms, this can be verbose — consider libraries like React Hook Form or Formik. Controlled inputs enable real-time validation and dynamic behavior. Always use onChange with value (or readOnly) to avoid React warnings.
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
console.log('Email:', email, 'Password:', password);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit">Login</button>
</form>
);
}Form with Multiple Fields
For forms with many fields, use a single state object and a generic handleChange function. The name attribute on each input matches the state key. The handler uses computed property names ([name]: value) to update the correct field. For checkboxes, use checked instead of value. This pattern reduces boilerplate significantly. For file inputs, use uncontrolled inputs (they can't be fully controlled). Consider React Hook Form for complex forms with validation.
function RegistrationForm() {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
country: 'us',
agree: false,
});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log(formData);
};
return (
<form onSubmit={handleSubmit}>
<input name="username" value={formData.username} onChange={handleChange} />
<input name="email" type="email" value={formData.email} onChange={handleChange} />
<select name="country" value={formData.country} onChange={handleChange}>
<option value="us">USA</option>
<option value="uk">UK</option>
</select>
<label>
<input type="checkbox" name="agree" checked={formData.agree} onChange={handleChange} />
Agree to terms
</label>
<button type="submit">Register</button>
</form>
);
}Uncontrolled Inputs
Uncontrolled inputs use refs to access the DOM value directly, without React state. The defaultValue prop sets the initial value (not value). This is simpler for forms that don't need real-time validation or dynamic behavior. File inputs must be uncontrolled (their value is read-only for security). Uncontrolled inputs are also useful for integrating with non-React code. The tradeoff: you can't easily validate or transform input in real-time. Prefer controlled inputs for most cases.
import { useRef } from 'react';
function UncontrolledForm() {
const emailRef = useRef(null);
const passwordRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
console.log('Email:', emailRef.current.value);
console.log('Password:', passwordRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} type="email" defaultValue="" />
<input ref={passwordRef} type="password" defaultValue="" />
<button type="submit">Submit</button>
</form>
);
}
// File input (must be uncontrolled)
function FileUpload() {
const fileRef = useRef(null);
return <input ref={fileRef} type="file" />;
}Validation & Error Handling
Form validation can be done on submit or on every change. The validate function returns an errors object — empty means valid. Display errors conditionally next to each field. For better UX, validate on blur (after the user leaves the field) rather than on every keystroke. Libraries like React Hook Form + Zod, or Formik + Yup, provide robust validation schemas, error management, and touch/blur tracking. Always validate on the server too — client validation is for UX, not security.
function ValidatedForm() {
const [values, setValues] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const validate = () => {
const errs = {};
if (!values.email) errs.email = 'Email is required';
else if (!/\S+@\S+\.\S+/.test(values.email)) errs.email = 'Invalid email';
if (!values.password) errs.password = 'Password is required';
else if (values.password.length < 8) errs.password = 'Min 8 characters';
return errs;
};
const handleSubmit = (e) => {
e.preventDefault();
const errs = validate();
setErrors(errs);
if (Object.keys(errs).length === 0) {
console.log('Form valid', values);
}
};
return (
<form onSubmit={handleSubmit}>
<input value={values.email}
onChange={e => setValues(v => ({ ...v, email: e.target.value }))} />
{errors.email && <span className="error">{errors.email}</span>}
<input type="password" value={values.password}
onChange={e => setValues(v => ({ ...v, password: e.target.value }))} />
{errors.password && <span className="error">{errors.password}</span>}
<button type="submit">Submit</button>
</form>
);
}Lists & Conditional Rendering
Rendering Lists
Use .map() to transform arrays into JSX elements. Each element needs a unique key prop — use stable IDs (todo.id), not array indices. Keys help React identify which items change (added, removed, reordered) for efficient DOM updates. Using index as key causes bugs when list items are reordered or inserted at the beginning. For empty lists, render a fallback message. Consider useMemo for filtered/sorted lists to avoid re-computation on every render.
function TodoList({ todos }) {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span>{todo.text}</span>
<button onClick={() => toggle(todo.id)}>
{todo.done ? 'Undo' : 'Done'}
</button>
</li>
))}
</ul>
);
}
// Filtering and sorting
function FilteredList({ items, filter }) {
const visible = items
.filter(item => item.category === filter)
.sort((a, b) => a.name.localeCompare(b.name));
return (
<ul>
{visible.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}Keys Explained
Keys must be unique among siblings (same parent), but can repeat across different lists. Keys help React's reconciliation algorithm: when a key changes, React destroys and recreates the component (losing state). With index keys, inserting an item at the beginning shifts all indices, causing React to re-render everything. With stable ID keys, React only renders the new item. Keys don't need to be globally unique — just unique within the list. Don't use random keys (Math.random()) — they change every render.
// GOOD: stable, unique keys
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
// BAD: index as key (causes bugs with reordering)
{todos.map((todo, index) => (
<TodoItem key={index} todo={todo} />
))}
// When index keys are OK:
// - Static list (never reordered/filtered)
// - List items have no state
// - List is never prepended to
// Key must be unique among siblings
function List() {
return (
<div>
{users.map(u => <User key={u.id} user={u} />)}
{posts.map(p => <Post key={p.id} post={p} />)}
{/* IDs can repeat across different lists */}
</div>
);
}Conditional Rendering Patterns
Multiple conditional rendering patterns exist. Early returns are cleanest for guard clauses (loading, error, auth). Element variables work for if/else in the middle of the component. Ternary (cond ? A : B) for either/or in JSX. Logical AND (cond && <X/>) for show/hide. Object lookup for switch-like behavior. Avoid nested ternaries — extract to variables or components. For numbers, use ternary instead of && (0 && <X/> renders 0).
function UserDashboard({ user, loading, error }) {
// 1. Early returns for loading/error states
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return <Login />;
// 2. Element variables
let greeting;
if (user.isAdmin) {
greeting = <h1>Welcome Admin {user.name}</h1>;
} else {
greeting = <h1>Welcome {user.name}</h1>;
}
return (
<div>
{greeting}
{/* 3. Ternary for either/or */}
{user.hasNotifications ? <NotificationBadge /> : null}
{/* 4. && for show/hide */}
{user.isAdmin && <AdminPanel />}
{/* 5. Switch via object lookup */}
{{ free: <FreePlan />, pro: <ProPlan />, enterprise: <EnterprisePlan /> }
[user.plan]}
</div>
);
}List Filtering & Searching
Searchable/filterable lists combine useState for filters with useMemo for performance. The filter function checks both query and category. Always handle the empty state (no results). For large lists (1000+ items), consider virtualization (react-window, react-virtualized) to only render visible items. Debounce search input for API calls. Case-insensitive search uses toLowerCase(). For complex filtering, extract to a separate function or custom hook.
function SearchableList({ items }) {
const [query, setQuery] = useState('');
const [category, setCategory] = useState('all');
// Memoize filtered results
const filtered = useMemo(() => {
return items.filter(item => {
const matchesQuery = item.name.toLowerCase().includes(query.toLowerCase());
const matchesCategory = category === 'all' || item.category === category;
return matchesQuery && matchesCategory;
});
}, [items, query, category]);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
<select value={category} onChange={e => setCategory(e.target.value)}>
<option value="all">All</option>
<option value="food">Food</option>
<option value="tech">Tech</option>
</select>
{filtered.length === 0 ? (
<p>No results found</p>
) : (
<ul>
{filtered.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
)}
</div>
);
}Dynamic Components
Dynamic component rendering uses a lookup object to map types to components. This is common in CMS-driven content, form builders, and page builders. The component map avoids long switch/if chains. Always handle unknown types with a fallback component. Spread props ({...block.props}) to pass all properties to the dynamic component. This pattern is flexible and extensible — adding a new block type just requires adding to the map. Capitalize the variable (Component) so JSX treats it as a component.
// Render different components based on type
const componentMap = {
text: TextBlock,
image: ImageBlock,
video: VideoBlock,
quote: QuoteBlock,
};
function ContentRenderer({ blocks }) {
return (
<div>
{blocks.map(block => {
const Component = componentMap[block.type];
if (!Component) return <UnknownBlock key={block.id} type={block.type} />;
return <Component key={block.id} {...block.props} />;
})}
</div>
);
}
// Usage
const blocks = [
{ id: 1, type: 'text', props: { content: 'Hello' } },
{ id: 2, type: 'image', props: { src: 'pic.jpg', alt: 'Picture' } },
];Performance Optimization
React.memo
React.memo wraps a component to prevent re-renders when props haven't changed (shallow comparison). Use it for components that render often with the same props. The second argument is a custom comparison function: return true to skip re-render, false to re-render. memo only helps if the component is expensive to render or is a child of a frequently-rendering parent. Don't wrap every component — memoization has overhead. Combine with useCallback/useMemo for maximum effect.
import { memo } from 'react';
// Memoized component: only re-renders if props change
const ExpensiveCard = memo(function ExpensiveCard({ title, content }) {
console.log('Card rendered');
return (
<div className="card">
<h2>{title}</h2>
<p>{content}</p>
</div>
);
});
// Custom comparison function
const MyComponent = memo(function MyComponent(props) {
return <div>{props.value}</div>;
}, (prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return prevProps.value === nextProps.value;
});Code Splitting
Code splitting reduces the initial bundle size by loading components on demand. React.lazy + Suspense enables dynamic imports. The fallback prop shows while the component loads. Route-based splitting (loading page components lazily) is the most impactful. Component-based splitting is useful for heavy components (charts, editors) that aren't needed immediately. Each lazy import creates a separate chunk. Use React.lazy for default exports; for named exports, wrap in a module.
import { lazy, Suspense } from 'react';
// Lazy load component (code-split)
const AdminPanel = lazy(() => import('./AdminPanel'));
const Dashboard = lazy(() => import('./Dashboard'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Router>
<Route path="/admin" element={<AdminPanel />} />
<Route path="/dashboard" element={<Dashboard />} />
</Router>
</Suspense>
);
}
// Route-based splitting (most common)
// Component-based splitting
const HeavyChart = lazy(() => import('./HeavyChart'));
function Page({ showChart }) {
return (
<div>
{showChart && (
<Suspense fallback={<Spinner />}>
<HeavyChart />
</Suspense>
)}
</div>
);
}Virtualization for Long Lists
Virtualization renders only the visible items in a long list, dramatically improving performance. react-window and react-virtualized are popular libraries. Instead of rendering 10,000 DOM nodes, only ~12 (visible ones) are rendered, with a scrollable container. This reduces DOM size and render time. Use virtualization for lists with 100+ items. The tradeoff: more complex implementation, potential issues with search/find (items not in DOM). Variable height lists need VariableSizeList.
import { FixedSizeList } from 'react-window';
// Virtualized list: only renders visible items
function BigList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
);
return (
<FixedSizeList
height={600}
width="100%"
itemCount={items.length}
itemSize={50}
>
{Row}
</FixedSizeList>
);
}
// Without virtualization: rendering 10,000 items is slow
// With virtualization: only ~12 visible items are renderedDebouncing & Throttling
Debouncing delays execution until a pause in activity (e.g., user stops typing). Throttling limits execution to once per interval. Both prevent excessive API calls or computations. The useDebounce hook updates the debounced value only after the user stops typing for the specified delay. This is essential for search inputs, resize handlers, and scroll events. For throttling, use a library like lodash.throttle or implement with timestamps. Always clean up timers in useEffect.
import { useState, useEffect } from 'react';
// Debounce hook: delays execution until user stops typing
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
// API call only fires when user stops typing for 300ms
useEffect(() => {
if (debouncedQuery) {
fetch('/api/search?q=' + debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}Profiling & Optimization
The Profiler component measures render times. phase is 'mount' or 'update'. actualDuration is the render time in milliseconds. Use React DevTools Profiler for visual flame charts. Before optimizing, profile to find actual bottlenecks — don't guess. Common performance issues: (1) unnecessary re-renders (fix with memo/useMemo/useCallback), (2) expensive calculations (fix with useMemo), (3) large lists (fix with virtualization), (4) large bundles (fix with code splitting). Premature optimization wastes time — measure first.
import { Profiler } from 'react';
function App() {
const onRender = (id, phase, actualDuration) => {
console.log(id + ' ' + phase + ': ' + actualDuration + 'ms');
};
return (
<Profiler id="App" onRender={onRender}>
<ExpensiveComponent />
</Profiler>
);
}
/* Optimization checklist:
1. React.memo for expensive components
2. useMemo for expensive calculations
3. useCallback for props passed to memoized children
4. Code splitting (lazy) for routes
5. Virtualization for long lists
6. Debounce rapid events (search, resize)
7. Avoid inline objects/functions as props
8. Use keys correctly in lists
9. Profile with React DevTools Profiler
10. Check unnecessary re-renders with why-did-you-render
*/Patterns & Error Boundaries
Custom Hooks
Custom hooks extract reusable stateful logic into a function prefixed with 'use'. They can call other hooks. Custom hooks are the primary way to share logic between components (replacing HOCs and render props). Return an object for multiple values, or a value/array for single values. Always handle loading and error states. The cancellation pattern (cancelled flag) prevents state updates after unmount. Name hooks with 'use' prefix for ESLint rules to work.
import { useState, useEffect } from 'react';
// Reusable data fetching hook
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(url)
.then(res => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(data => { if (!cancelled) { setData(data); setError(null); }})
.catch(err => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Usage
function UserProfile({ id }) {
const { data: user, loading, error } = useFetch('/api/users/' + id);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <h1>{user.name}</h1>;
}useLocalStorage Hook
useLocalStorage persists state to localStorage. The lazy initializer reads from localStorage on mount. The useEffect writes to localStorage whenever the value changes. The try/catch handles quota exceeded errors and JSON parse errors (corrupted data). This pattern works for any persistent state: themes, user preferences, draft content. For SSR compatibility, check typeof window !== 'undefined'. For cross-tab sync, listen to the 'storage' event. Similar hooks: useSessionStorage, useCookie.
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error('LocalStorage error:', e);
}
}, [key, value]);
return [value, setValue];
}
// Usage
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Current: {theme}
</button>
);
}Error Boundaries
Error boundaries catch errors in child components' render/lifecycle methods, preventing the entire app from crashing. They must be class components (no hook equivalent yet). getDerivedStateFromError updates state to show fallback UI. componentDidCatch logs errors (send to Sentry, LogRocket, etc.). Error boundaries DON'T catch: event handlers, async code, setTimeout, errors in the boundary itself. Wrap specific sections to isolate failures. The 'Try again' button resets the error state.
import { Component } from 'react';
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('Caught error:', error, errorInfo);
// Send to error reporting service
// logErrorToService(error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div>
<h1>Something went wrong.</h1>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
)
);
}
return this.props.children;
}
}
// Usage: wrap components
<ErrorBoundary fallback={<ErrorPage />}>
<App />
</ErrorBoundary>Higher-Order Components (HOC)
Higher-Order Components (HOCs) are functions that take a component and return an enhanced one. They were the primary pattern for sharing logic before hooks. Common uses: authentication, loading states, theming. HOCs can cause 'wrapper hell' (deeply nested components) and prop collisions. For new code, prefer custom hooks — they're simpler, more composable, and don't add to the component tree. HOCs are still useful for class components or when integrating with libraries that require them.
// HOC: function that takes a component and returns a new one
function withLoading(Component) {
return function WithLoading({ isLoading, ...props }) {
if (isLoading) return <div>Loading...</div>;
return <Component {...props} />;
};
}
// HOC for authentication
function withAuth(Component) {
return function WithAuth(props) {
const { user } = useContext(AuthContext);
if (!user) return <Redirect to="/login" />;
return <Component {...props} user={user} />;
};
}
// Usage
const UserList = withLoading(withAuth(BaseUserList));
// Note: Prefer hooks over HOCs for new code
// HOCs are mainly for class components or library compatibilityCompound Components
Compound components let users compose a complex component from simple parts. The parent (Select) provides context, and child components (Trigger, Options, Option) consume it. This pattern is used by libraries like Radix UI, Headless UI, and React Aria. Benefits: flexible API (users can reorder/omit parts), implicit state sharing via context, clean JSX. The components are attached as static properties (Select.Trigger). This is an advanced pattern — use it for reusable UI libraries, not one-off components.
// Compound components: components that work together
function Select({ children, value, onChange }) {
const [isOpen, setIsOpen] = useState(false);
const context = { value, onChange, isOpen, setIsOpen };
return (
<SelectContext.Provider value={context}>
<div className="select">{children}</div>
</SelectContext.Provider>
);
}
Select.Trigger = function Trigger({ children }) {
const { isOpen, setIsOpen } = useContext(SelectContext);
return <button onClick={() => setIsOpen(!isOpen)}>{children}</button>;
};
Select.Options = function Options({ children }) {
const { isOpen } = useContext(SelectContext);
return isOpen ? <div className="options">{children}</div> : null;
};
Select.Option = function Option({ value, children }) {
const { onChange, setIsOpen } = useContext(SelectContext);
return (
<div onClick={() => { onChange(value); setIsOpen(false); }}>
{children}
</div>
);
}
// Usage: clean, declarative API
<Select value={val} onChange={setVal}>
<Select.Trigger>Choose...</Select.Trigger>
<Select.Options>
<Select.Option value="a">Option A</Select.Option>
<Select.Option value="b">Option B</Select.Option>
</Select.Options>
</Select>Custom Hooks
useFetch Hook
Custom hooks extract reusable stateful logic into a function prefixed with 'use'. useFetch encapsulates data fetching with loading/error states. The AbortController cancels in-flight requests when the component unmounts or the URL changes (preventing race conditions and memory leaks). Always include cleanup in useEffect for async operations. Custom hooks can call other hooks (useState, useEffect, useContext). They're the primary way to share logic between components without render props or HOCs. Name them with 'use' prefix so React's rules-of-hooks linter works.
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let abortController = new AbortController();
setLoading(true);
fetch(url, { ...options, signal: abortController.signal })
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.json();
})
.then((data) => { setData(data); setError(null); })
.catch((err) => {
if (err.name !== "AbortError") setError(err.message);
})
.finally(() => setLoading(false));
return () => abortController.abort();
}, [url]);
return { data, loading, error };
}
// Usage
function Profile({ userId }) {
const { data, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <div>{data.name}</div>;
}useLocalStorage Hook
useLocalStorage synchronizes React state with localStorage. The lazy initializer reads from localStorage on first render only. The useEffect writes to localStorage whenever the value changes. The try/catch handles cases where localStorage is full or disabled (private browsing). This hook makes persistent state as easy as useState. For cross-tab synchronization, add a storage event listener. For SSR safety, guard window access. This pattern works for sessionStorage too — just swap the API.
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = window.localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error("LocalStorage write failed:", e);
}
}, [key, value]);
return [value, setValue];
}
// Usage: persists state across page reloads
function Settings() {
const [theme, setTheme] = useLocalStorage("theme", "light");
const [fontSize, setFontSize] = useLocalStorage("fontSize", 14);
return (
<div>
<button onClick={() => setTheme("dark")}>Dark</button>
<button onClick={() => setTheme("light")}>Light</button>
</div>
);
}useDebounce Hook
useDebounce delays updating a value until the user stops typing for the specified delay. This is essential for search inputs, autosave, and API calls triggered by user input — it prevents excessive calls on every keystroke. The cleanup function clears the timeout if the value changes again before the delay expires. The debounced value only updates after the pause, triggering downstream effects (like API calls) less frequently. For immediate execution with a trailing call, use useThrottle instead. Combine with useFetch for efficient search-as-you-type.
function useDebounce(value, delay = 500) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Usage: debounce search input
function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
fetch(`/api/search?q=${debouncedQuery}`)
.then((res) => res.json())
.then(setResults);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}usePrevious Hook
usePrevious leverages the fact that useEffect runs after render — ref.current still holds the old value during render, then updates to the new value after. This is a common pattern for comparing current and previous state. useWindowSize tracks viewport dimensions with a resize listener. Always clean up event listeners in the useEffect return to prevent memory leaks. These utility hooks demonstrate how custom hooks encapsulate DOM-related logic, making components cleaner and the logic reusable and testable.
function usePrevious(value) {
const ref = useRef(null);
useEffect(() => {
ref.current = value; // update AFTER render
}, [value]);
return ref.current; // returns previous value during render
}
// Usage: compare current vs previous
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>Now: {count}, before: {prevCount}</p>
{count > prevCount && <p>Increased!</p>}
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
// useWindowSize hook
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handler = () =>
setSize({ width: innerWidth, height: innerHeight });
window.addEventListener("resize", handler);
return () => removeEventListener("resize", handler);
}, []);
return size;
}useToggle & useClipboard
useToggle simplifies boolean state with a toggle function wrapped in useCallback for stable identity. useClipboard wraps the clipboard API with a 'copied' feedback state that auto-resets after a timeout. These small utility hooks reduce boilerplate and standardize common patterns across your app. The useCallback in both hooks prevents unnecessary re-renders of memoized children. Building a library of small, focused hooks (useToggle, useClipboard, useMediaQuery, useOnClickOutside) accelerates development and ensures consistent behavior.
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((p) => !p), []);
return [on, toggle, setOn];
}
function useClipboard(timeout = 2000) {
const [copied, setCopied] = useState(false);
const copy = useCallback((text) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), timeout);
});
}, [timeout]);
return [copied, copy];
}
// Usage
function CopyButton({ text }) {
const [copied, copy] = useClipboard();
return (
<button onClick={() => copy(text)}>
{copied ? "Copied!" : "Copy"}
</button>
);
}
function Modal({ children }) {
const [isOpen, toggle] = useToggle(false);
return (
<>
<button onClick={toggle}>Open</button>
{isOpen && <div className="modal">{children}</div>}
</>
);
}Portals
Creating a Portal
createPortal renders children into a DOM node outside the current component's hierarchy (typically document.body). This is essential for modals, tooltips, and dropdowns that must escape parent CSS constraints (overflow: hidden, z-index stacking contexts, transform creating new contexts). Despite rendering elsewhere in the DOM, the portal's React event bubbling still works as if it were in the original tree — onClick handlers on ancestors still fire. This gives you the best of both worlds: visual escape from parent constraints, but logical event flow preserved.
import { createPortal } from "react-dom";
function Modal({ children, onClose }) {
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose}>✕</button>
{children}
</div>
</div>,
document.body // render target outside the DOM hierarchy
);
}
// Usage: the modal renders at body level,
// escaping any overflow:hidden or z-index stacking contexts
function App() {
const [show, setShow] = useState(false);
return (
<div style={{ overflow: "hidden", position: "relative" }}>
<button onClick={() => setShow(true)}>Open Modal</button>
{show && <Modal onClose={() => setShow(false)}>Hello!</Modal>}
</div>
);
}Modal with Portal & Focus Trap
A production modal needs more than just a portal: focus management (trap focus inside, restore on close), Escape key handling, body scroll locking, and click-outside-to-close. This implementation saves the previously focused element, focuses the modal on open, and restores focus on close — essential for screen reader users. Body overflow hidden prevents background scrolling. The cleanup function restores everything. For complete focus trapping (tab cycling within modal), use a library like focus-trap-react. Always return null when closed to remove from DOM.
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
const modal = modalRef.current;
const previouslyFocused = document.activeElement;
modal.focus();
const handleKey = (e) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
// Prevent body scroll
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", handleKey);
document.body.style.overflow = "";
previouslyFocused.focus(); // restore focus
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="overlay" onClick={onClose}>
<div ref={modalRef} tabIndex={-1} className="modal">
{children}
</div>
</div>,
document.body
);
}Tooltips with Portals
Tooltips benefit from portals because they must overflow parent containers and avoid clipping. The tooltip position is calculated from the trigger's getBoundingClientRect() and rendered with position: fixed at the body level. This avoids z-index and overflow issues. For dynamic positioning (flip when near screen edge), use a library like Floating UI (formerly Popper.js). The portal ensures the tooltip is never clipped by overflow: hidden ancestors. Fixed positioning coordinates are relative to the viewport, making calculation straightforward.
function Tooltip({ children, text }) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0 });
const targetRef = useRef(null);
const show = () => {
const rect = targetRef.current.getBoundingClientRect();
setCoords({ x: rect.left, y: rect.top - 40 });
setVisible(true);
};
return (
<>
<span
ref={targetRef}
onMouseEnter={show}
onMouseLeave={() => setVisible(false)}
>
{children}
</span>
{visible && createPortal(
<div style={{ position: "fixed", left: coords.x, top: coords.y }}
className="tooltip">
{text}
</div>,
document.body
)}
</>
);
}
// Usage
<Tooltip text="Click to save">💾</Tooltip>Dropdown Menus with Portals
Dropdown menus face the same overflow/z-index issues as tooltips. Portals solve the visual problem. The click-outside handler checks if the click target is outside the trigger ref. The capture-phase scroll listener (true as third arg) closes the menu on any scroll, preventing the menu from detaching from its trigger. For production, use Floating UI which handles edge detection, flipping, shifting, and automatic positioning updates on scroll/resize. Portals + proper positioning logic = robust dropdowns that work in any layout context.
function Dropdown({ trigger, children }) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0 });
const ref = useRef(null);
const handleOpen = () => {
const rect = ref.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left });
setOpen(true);
};
useEffect(() => {
if (!open) return;
const handleClick = (e) => {
if (!ref.current?.contains(e.target)) setOpen(false);
};
const handleScroll = () => setOpen(false); // close on scroll
document.addEventListener("mousedown", handleClick);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleClick);
window.removeEventListener("scroll", handleScroll, true);
};
}, [open]);
return (
<>
<div ref={ref} onClick={handleOpen}>{trigger}</div>
{open && createPortal(
<div style={{ position: "fixed", ...pos }} className="dropdown">
{children}
</div>,
document.body
)}
</>
);
}Portal Event Bubbling
A key feature of React Portals: event bubbling follows the React component tree, not the DOM tree. onClick on a parent component fires even when the child is portaled to document.body. This means context, state, and event delegation all work naturally. However, CSS inheritance does NOT cross the portal boundary — styles on the parent don't cascade to portaled content since they're in different DOM subtrees. You must explicitly apply CSS (via classes or CSS variables on :root) to style portal content. This separation is usually desirable for modals/tooltips.
function PortalExample() {
// Despite rendering in document.body, events bubble
// through the React tree, not the DOM tree
return (
<div onClick={() => console.log("Parent clicked!")}>
<p>Click the button — parent handler fires!</p>
{createPortal(
<button onClick={() => console.log("Button clicked!")}>
I'm in a portal
</button>,
document.body
)}
</div>
);
}
// Clicking logs: "Button clicked!" then "Parent clicked!"
// This means context, state, and event delegation
// all work as if the portal were inline
// But CSS inheritance does NOT cross the portal boundary:
// document.body styles won't inherit into the portal content
// unless you explicitly apply themSuspense & Lazy Loading
React.lazy & Suspense
React.lazy dynamically imports a component, creating a separate bundle that loads on demand (code splitting). Wrap lazy components in <Suspense> with a fallback (loading state) shown while the chunk downloads. This reduces initial bundle size — users only download code for pages they visit. Each lazy() call creates a separate chunk. For route-based splitting, lazy-load each page component. The fallback can be any React node (spinner, skeleton, text). Suspense can wrap multiple lazy components — the fallback shows until all are ready.
import { lazy, Suspense } from "react";
// Lazy-load component (code-split)
const Dashboard = lazy(() => import("./Dashboard"));
const Settings = lazy(() => import("./Settings"));
function App() {
return (
<Suspense fallback={<div>Loading page...</div>}>
<nav>
<button onClick={() => setPage("dash")}>Dashboard</button>
<button onClick={() => setPage("settings")}>Settings</button>
</nav>
{page === "dash" && <Dashboard />}
{page === "settings" && <Settings />}
</Suspense>
);
}Nested Suspense
Nested Suspense boundaries create a 'peeling' effect where content reveals progressively as each chunk loads. Outer Suspense shows its fallback first; as inner components load, they reveal independently. This prevents a single slow component from blocking the entire page. Place Suspense boundaries strategically: around route-level pages (coarse), around major sections (medium), and around independent widgets (fine). Too many boundaries create janky loading; too few create long waits. The key is matching boundaries to user-perceived content units.
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<MainSkeleton />}>
<MainContent /> {/* loads first */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* loads independently, doesn't block MainContent */}
</Suspense>
</Suspense>
<Sidebar />
</Suspense>
// Suspense "peeling" effect:
// 1. PageSkeleton shows
// 2. Header + Sidebar load → PageSkeleton peels away
// 3. MainSkeleton shows until MainContent loads
// 4. MainContent shows, CommentsSkeleton shows
// 5. Comments load → everything visibleLazy with Error Boundaries
Lazy loading can fail (network issues, deployments invalidating chunk URLs). Error Boundaries catch these errors and show fallback UI. Always wrap Suspense + lazy in an ErrorBoundary. The componentDidCatch logs errors for monitoring. For retry logic, you can reset the ErrorBoundary's state or reload the page. A common pattern is a retry button that re-imports the chunk. Without error boundaries, a failed chunk load crashes the entire app. This is critical for production — network reliability is never 100%.
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
console.error("Chunk load failed:", error, info);
}
render() {
if (this.state.hasError) {
return (
<div>
<p>Failed to load. {this.state.error.message}</p>
<button onClick={() => window.location.reload()}>
Retry
</button>
</div>
);
}
return this.props.children;
}
}
// Wrap lazy components: network failures need handling
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<LazyComponent />
</Suspense>
</ErrorBoundary>Data Fetching with Suspense
React 19's use() hook enables Suspense for data fetching. Unlike useEffect, use() suspends the component until the promise resolves — the nearest Suspense boundary shows its fallback. Multiple use() calls in the same component resolve concurrently (parallel fetching). This eliminates manual loading state management. The promise can be cached outside React to prevent refetching on re-render. Note: use() can only be called in render or inside hooks. For React 18, use libraries like React Query or SWR which integrate with Suspense.
// React 18+ Suspense for data fetching (experimental)
import { use } from "react"; // React 19+
// Wrap a promise with use()
function UserProfile({ userId }) {
// 'use' suspends until the promise resolves
const user = use(fetchUser(userId));
return <div>{user.name}</div>;
}
function fetchUser(id) {
return fetch(`/api/users/${id}`).then((r) => r.json());
}
// Parent provides Suspense boundary
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile userId={1} />
</Suspense>
);
}
// Concurrent: multiple suspends resolve together
function Dashboard() {
const user = use(fetchUser(1));
const posts = use(fetchPosts(user.id));
// Both fetch in parallel, Suspense shows until all resolve
return <div>{user.name}: {posts.length} posts</div>;
}Suspense List (Orchestration)
SuspenseList orchestrates the reveal order of multiple Suspense boundaries. revealOrder='forwards' shows items in order (item 2 won't reveal until item 1 is ready, even if 2 loads first) — prevents content jumping. 'together' waits for all before revealing. 'backwards' reveals bottom-to-top. tail='collapsed' hides loading states for items not yet started; 'hidden' hides all fallbacks. This is useful for feeds and lists where order matters. Note: SuspenseList was experimental and its API may change — check current React docs for availability.
// SuspenseList controls reveal order of multiple Suspense
import { SuspenseList, Suspense } from "react";
function Article({ id }) {
const data = use(fetchArticle(id));
return <article>{data.title}</article>;
}
function Feed() {
return (
<SuspenseList revealOrder="forwards" tail="collapsed">
{/* "forwards": reveals top-to-bottom as they load */}
{/* "together": waits for all, reveals together */}
{/* "backwards": reveals bottom-to-top */}
<Suspense fallback={<Skeleton />}>
<Article id={1} />
</Suspense>
<Suspense fallback={<Skeleton />}>
<Article id={2} />
</Suspense>
<Suspense fallback={<Skeleton />}>
<Article id={3} />
</Suspense>
</SuspenseList>
);
}React Router
Basic Routing
React Router v6 uses <BrowserRouter> as the root, <Routes> to define route matching, and <Route> with element prop (not component). <Link> creates navigation links that use the History API (no page reload). Dynamic segments (:id) are accessed via useParams(). path='*' is a catch-all for 404s. Routes are matched by best match, not order. For URL search params (?q=search), use useSearchParams(). BrowserRouter requires server configuration to serve index.html for all routes (SPA fallback).
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/users/123">User 123</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/users/:id" element={<UserPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
function UserPage() {
const { id } = useParams();
return <h1>User ID: {id}</h1>;
}Nested Routes & Outlet
Nested routes create layout hierarchies. The parent route's element must include <Outlet /> where child routes render. The index route renders at the parent's path. Deeply nested routes (users/:id) create nested layouts — Users layout wraps UserDetail. This is powerful for dashboards with persistent sidebars/headers. useOutlet() gives access to the child element. The URL /users/123 renders Layout → Users → UserDetail, each contributing their layout. This replaces manual conditional rendering of layouts.
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="users" element={<Users />}>
<Route path=":id" element={<UserDetail />} />
</Route>
</Route>
</Routes>
</BrowserRouter>
);
}
function Layout() {
return (
<div>
<nav>Navigation here</nav>
<Outlet /> {/* Child routes render here */}
</div>
);
}
function Users() {
return (
<div>
<h2>Users</h2>
<Outlet /> {/* Nested :id route renders here */}
</div>
);
}Navigation & Redirects
useNavigate returns a function for programmatic navigation. navigate('/path', { replace: true }) replaces history (no back button). Pass state to carry data to the next route (e.g., where to return after login). <Navigate> is the declarative redirect component — use it in render for auth guards. NavLink provides isActive for styling active links. useLocation gives the current URL, pathname, search, hash, and state. For redirects after actions (form submit), use navigate. For conditional redirects in render, use <Navigate>.
import { useNavigate, Navigate, NavLink, useLocation } from "react-router-dom";
function Login() {
const navigate = useNavigate();
const location = useLocation();
const handleLogin = async () => {
await auth.login();
// Redirect to intended page or home
const from = location.state?.from || "/";
navigate(from, { replace: true });
};
return <button onClick={handleLogin}>Login</button>;
}
// Declarative redirect
function ProtectedRoute({ user, children }) {
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
// NavLink: active styling
<NavLink to="/about" className={({ isActive }) =>
isActive ? "nav-active" : "nav"
}>
About
</NavLink>Loaders & Data Loading
React Router v6.4+ (data router) adds loaders (run before route renders) and actions (handle form submissions). useLoaderData() accesses loader data — no more useEffect for route data fetching. Loaders run in parallel for nested routes. errorElement catches errors from loaders/actions. Actions process form submissions via <Form method='post'> — useActionData() returns the result. This pattern (inspired by Remix) co-locates data logic with routes. The data APIs require createBrowserRouter/createHashRouter, not <BrowserRouter>.
import { createBrowserRouter, RouterProvider } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/users/:id",
element: <UserPage />,
loader: async ({ params }) => {
const res = await fetch(`/api/users/${params.id}`);
if (!res.ok) throw new Response("Not found", { status: 404 });
return res.json();
},
errorElement: <ErrorPage />,
},
]);
function UserPage() {
const user = useLoaderData(); // data from loader
return <h1>{user.name}</h1>;
}
// Action for form submissions
{
path: "/users/new",
element: <NewUser />,
action: async ({ request }) => {
const formData = await request.formData();
const res = await fetch("/api/users", {
method: "POST",
body: formData,
});
return redirect(`/users/${res.id}`);
},
}
function App() {
return <RouterProvider router={router} />;
}Route Guards & Protected Routes
Route guards protect routes based on auth state or roles. RequireAuth redirects unauthenticated users to login, preserving the intended destination in location.state for post-login redirect. RequireRole adds role-based access control. Compose guards by wrapping them (RequireAuth > RequireRole > component). For layout routes, you can also use element={<RequireAuth><Outlet/></RequireAuth>} to protect all child routes at once. Always check auth on the server too — client-side guards are for UX, not security. The pattern scales to any condition: subscription, feature flags, etc.
function RequireAuth({ children }) {
const { user } = useAuth();
const location = useLocation();
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="dashboard" element={
<RequireAuth><Dashboard /></RequireAuth>
} />
<Route path="admin" element={
<RequireAuth><RequireRole role="admin"><Admin /></RequireRole></RequireAuth>
} />
</Route>
</Routes>
</BrowserRouter>
);
}
function RequireRole({ role, children }) {
const { user } = useAuth();
if (user?.role !== role) return <Navigate to="/forbidden" />;
return children;
}State Management (Context & Redux)
Context API Pattern
Context provides global state without prop drilling. Create a context, wrap consumers in a Provider, and access via useContext. The custom useAuth hook adds error checking and is the recommended API surface. Context is ideal for low-frequency updates (auth, theme, locale). For high-frequency state changes, Context causes all consumers to re-render on every change — use useReducer for complex state or split contexts. Always co-locate the provider with the state it manages. Context value should be memoized with useMemo/useCallback if it contains functions.
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = async (credentials) => {
const user = await api.login(credentials);
setUser(user);
};
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be inside AuthProvider");
return ctx;
}
// Usage
function Navbar() {
const { user, logout } = useAuth();
return user
? <button onClick={logout}>Logout {user.name}</button>
: <Link to="/login">Login</Link>;
}
// Wrap app: <AuthProvider><App /></AuthProvider>useReducer + Context
useReducer + Context is the recommended pattern for complex global state without external libraries. The reducer centralizes state logic (predictable, testable transitions). The provider memoizes the value to prevent unnecessary re-renders. Derived values (total) are computed in useMemo. This pattern handles cart, form state, multi-step wizards, etc. For truly complex apps with middleware, time-travel debugging, or many independent slices, consider Redux Toolkit or Zustand. But for most apps, useReducer + Context is sufficient and has zero dependencies.
const CartContext = createContext();
function cartReducer(state, action) {
switch (action.type) {
case "ADD":
const existing = state.find((i) => i.id === action.item.id);
if (existing) {
return state.map((i) =>
i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i
);
}
return [...state, { ...action.item, qty: 1 }];
case "REMOVE":
return state.filter((i) => i.id !== action.id);
case "CLEAR":
return [];
default:
return state;
}
}
function CartProvider({ children }) {
const [items, dispatch] = useReducer(cartReducer, []);
const value = useMemo(() => ({
items,
total: items.reduce((s, i) => s + i.price * i.qty, 0),
addItem: (item) => dispatch({ type: "ADD", item }),
removeItem: (id) => dispatch({ type: "REMOVE", id }),
}), [items]);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}Redux Toolkit Basics
Redux Toolkit (RTK) is the modern, recommended way to use Redux. createSlice auto-generates action creators and reducers. It uses Immer internally, so you 'mutate' state directly (state.value += 1) and Immer produces the immutable update. configureStore sets up the store with sensible defaults (Redux DevTools, thunk middleware). useSelector reads state; useDispatch dispatches actions. RTK eliminates Redux boilerplate (no switch statements, no action type constants). For async logic, use createAsyncThunk. RTK Query (included) handles data fetching and caching.
import { configureStore, createSlice } from "@reduxjs/toolkit";
import { useSelector, useDispatch } from "react-redux";
const counterSlice = createSlice({
name: "counter",
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1; }, // Immer: mutate safely
decrement: (state) => { state.value -= 1; },
addBy: (state, action) => { state.value += action.payload; },
},
});
const store = configureStore({
reducer: { counter: counterSlice.reducer },
});
export const { increment, decrement, addBy } = counterSlice.actions;
// Usage in component
function Counter() {
const count = useSelector((state) => state.counter.value);
const dispatch = useDispatch();
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(increment())}>+</button>
<button onClick={() => dispatch(addBy(5))}>+5</button>
</div>
);
}Zustand (Lightweight Alternative)
Zustand is a minimal state management library — no providers, no boilerplate. Create a store with create(), access via hooks with selector functions. Selectors prevent re-renders: only components using the changed slice re-render. This solves Context's re-render problem without Redux's complexity. For object selectors (returning {a, b}), use shallow comparison to prevent unnecessary re-renders. Zustand supports middleware (persist, devtools, immer). It's ideal for small-to-medium apps where Redux is overkill but Context causes too many re-renders. The API is tiny but powerful.
import { create } from "zustand";
const useStore = create((set, get) => ({
count: 0,
user: null,
increment: () => set((state) => ({ count: state.count + 1 })),
setUser: (user) => set({ user }),
reset: () => set({ count: 0, user: null }),
// Access other state with get()
doubleCount: () => get().count * 2,
}));
// Usage: select only what you need (prevents re-renders)
function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
// Multiple selections
function Profile() {
const { user, setUser } = useStore(
(state) => ({ user: state.user, setUser: state.setUser })
);
// Use shallow comparison for object selectors
// import { shallow } from "zustand/shallow";
// useStore(selector, shallow);
}React Query (Server State)
React Query (TanStack Query) manages server state — data fetched from APIs. It handles caching, background refetching, stale data, optimistic updates, and pagination automatically. queryKey identifies cached data (like a cache key). staleTime controls how long data is considered fresh. invalidateQueries after mutations refetches dependent queries. Unlike Redux (client state), React Query is purpose-built for async server data. It eliminates manual loading/error states, useEffect fetching, and cache management. For most apps, React Query + local state (useState/useReducer) replaces Redux entirely.
import { useQuery, useMutation, QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Users />
</QueryClientProvider>
);
}
function Users() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["users"],
queryFn: () => fetch("/api/users").then((r) => r.json()),
staleTime: 60000, // fresh for 60s
refetchOnWindowFocus: true,
});
const mutation = useMutation({
mutationFn: (newUser) =>
fetch("/api/users", { method: "POST", body: JSON.stringify(newUser) }),
onSuccess: () => queryClient.invalidateQueries(["users"]),
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
{data.map((u) => <div key={u.id}>{u.name}</div>)}
<button onClick={() => mutation.mutate({ name: "New" })}>Add</button>
</div>
);
}Testing (React Testing Library)
Basic Component Test
React Testing Library (RTL) tests components as users interact with them — by role, label, and text, not implementation details. getByRole is the preferred query (tests accessibility too). userEvent simulates real user interactions (typing, clicking) more accurately than fireEvent. Tests should avoid testing internal state; instead, verify visible output and behavior. If you can't query by role, use getByLabelText, getByText, or getByDisplayValue. Avoid getByTestId unless necessary. This approach makes tests resilient to refactoring — they test what users see and do.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";
test("counter increments on click", async () => {
const user = userEvent.setup();
render(<Counter />);
// Find by accessible role (not test-id)
expect(screen.getByRole("heading")).toHaveTextContent("0");
await user.click(screen.getByRole("button", { name: /increment/i }));
expect(screen.getByRole("heading")).toHaveTextContent("1");
});
test("displays error for invalid input", async () => {
const user = userEvent.setup();
render(<Form />);
await user.type(screen.getByLabelText(/email/i), "not-an-email");
await user.click(screen.getByRole("button", { name: /submit/i }));
expect(screen.getByRole("alert")).toHaveTextContent(/invalid email/i);
});Testing Hooks
renderHook tests custom hooks in isolation. result.current holds the hook's return value. All state updates must be wrapped in act() to ensure React processes them synchronously. rerender lets you test effects that depend on changing props. For async hooks (useEffect with fetch), use waitFor or findBy queries (which wait for updates). Testing hooks directly is faster and more focused than testing through a component. However, also test hooks through component integration tests to verify real-world usage. renderHook is available in @testing-library/react v13+.
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";
test("useCounter increments and decrements", () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
// Wrap state updates in act()
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
act(() => result.current.reset());
expect(result.current.count).toBe(0);
});
// Testing with initial props that change
test("useEffect runs on dependency change", () => {
const { result, rerender } = renderHook(
({ id }) => useFetchUser(id),
{ initialProps: { id: 1 } }
);
rerender({ id: 2 });
// Effect re-ran with new id
});Testing Async & Mocking
MSW (Mock Service Worker) intercepts network requests at the service worker level — tests use real fetch() but get mocked responses. This is more realistic than mocking fetch directly. setupServer for Node (Jest), setupWorker for browser. beforeAll/afterAll lifecycle manages the server. server.use() overrides handlers per-test. findBy queries (async) wait for elements to appear — use for async rendering. queryBy returns null if not found (for asserting absence). waitFor polls for a condition. MSW can also be used for development mocking and Storybook.
import { render, screen, waitFor } from "@testing-library/react";
import { rest } from "msw";
import { setupServer } from "msw/node";
import { UserProfile } from "./UserProfile";
// Mock API with MSW (Mock Service Worker)
const server = setupServer(
rest.get("/api/users/:id", (req, res, ctx) => {
return res(ctx.json({ id: 1, name: "Alice" }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("displays user after fetch", async () => {
render(<UserProfile id={1} />);
// findBy waits for async update
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(screen.queryByText("Loading")).not.toBeInTheDocument();
});
test("shows error on fetch failure", async () => {
server.use(
rest.get("/api/users/:id", (req, res, ctx) =>
res(ctx.status(500))
)
);
render(<UserProfile id={1} />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});Testing Context & Providers
Create a custom render utility that wraps components with required providers (Theme, Auth, Router, etc.). This avoids repeating provider setup in every test. Re-export RTL functions from your test-utils file so tests import from there. For router testing, use MemoryRouter (not BrowserRouter) with initialEntries to set the starting URL — no real browser history needed. For Redux, wrap in a test Provider with a real or mock store. This pattern keeps tests clean and ensures all components have their required context. It's the standard setup for any React testing infrastructure.
// Custom render that wraps with providers
import { render } from "@testing-library/react";
import { ThemeProvider } from "./ThemeProvider";
function customRender(ui, { theme = "light", ...options } = {}) {
function Wrapper({ children }) {
return <ThemeProvider initialTheme={theme}>{children}</ThemeProvider>;
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Re-export everything
export * from "@testing-library/react";
export { customRender as render };
// In test files, import from your test-utils:
// import { render, screen } from "../test-utils";
test("button uses theme color", () => {
customRender(<Button>Click</Button>, { theme: "dark" });
expect(screen.getByRole("button")).toHaveClass("btn-dark");
});
// Testing with router
import { MemoryRouter } from "react-router-dom";
render(
<MemoryRouter initialEntries={["/users/123"]}>
<App />
</MemoryRouter>
);Testing Events & Interactions
userEvent.setup() creates a user instance for realistic interactions: type (character by character), click, tab, keyboard (with key codes like {Escape}, {Enter}), selectOptions, upload, and more. Always await user interactions — they're async. Testing keyboard navigation is crucial for accessibility. For form testing, fill all fields and verify onSubmit receives correct data. userEvent is preferred over fireEvent because it simulates real browser behavior (focus, blur, input events in correct order). Test the full user flow, not individual event handlers.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("form submission flow", async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
// Fill form fields
await user.type(screen.getByLabelText(/email/i), "[email protected]");
await user.type(screen.getByLabelText(/password/i), "password123");
// Submit
await user.click(screen.getByRole("button", { name: /login/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: "[email protected]",
password: "password123",
});
});
test("keyboard navigation", async () => {
const user = userEvent.setup();
render(<Modal />);
await user.tab(); // focus first element
expect(screen.getByRole("button", { name: /close/i })).toHaveFocus();
await user.keyboard("{Escape}"); // press Escape
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});TypeScript + React
Component Props Typing
TypeScript with React provides type safety for props. Use interfaces or type aliases for props. Optional props use ?. Union types (variant) constrain values. React.ReactNode accepts any renderable content (strings, elements, arrays). Extending HTML attributes (React.InputHTMLAttributes) lets your component accept all native attributes (placeholder, onChange, etc.) while adding custom props. The spread {...rest} passes remaining attributes to the native element. This pattern creates type-safe, flexible components. Always export prop types so consumers can reference them.
// Basic props
interface ButtonProps {
text: string;
onClick: () => void;
variant?: "primary" | "secondary"; // union type
disabled?: boolean;
}
function Button({ text, onClick, variant = "primary", disabled }: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
disabled={disabled}
>
{text}
</button>
);
}
// Children prop
interface CardProps {
title: string;
children: React.ReactNode; // any renderable content
}
// Extending HTML attributes
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
function Input({ label, error, ...rest }: InputProps) {
return (
<label>
{label}
<input {...rest} />
{error && <span className="error">{error}</span>}
</label>
);
}Hooks with TypeScript
TypeScript adds type safety to hooks. useState<T> specifies the state type; useState<T | null>(null) for nullable state. useRef<T>(null) types the ref — current is T | null. For useContext, define a context type and throw if undefined (so consumers get the non-undefined type). For useReducer, type the Action as a discriminated union — the switch on action.type narrows the type in each case, giving you type-safe payload access. These patterns eliminate runtime errors from undefined access and incorrect action payloads.
// useState with types
const [count, setCount] = useState<number>(0);
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<string[]>([]);
// useRef
const inputRef = useRef<HTMLInputElement>(null);
// Access: inputRef.current?.focus()
// useContext
interface ThemeContextType {
theme: "light" | "dark";
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be inside ThemeProvider");
return ctx; // ctx is now ThemeContextType, not undefined
}
// useReducer
type Action = { type: "increment" } | { type: "set"; value: number };
const [state, dispatch] = useReducer((state: number, action: Action) => {
switch (action.type) {
case "increment": return state + 1;
case "set": return action.value;
}
}, 0);Generic Components
Generic components and hooks work with any data type while maintaining type safety. The <T> type parameter is inferred from the items prop, so renderItem and keyExtractor automatically receive the correct type. This is how TypeScript recreates generic utility components (List, Table, Select) with full type safety. Generic hooks (useArray<T>) similarly preserve types through operations. The key insight: TypeScript infers T from usage, so consumers rarely need to specify it explicitly. This pattern is essential for building reusable, type-safe component libraries.
// Generic component: works with any type
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage: TypeScript infers T from items
<List
items={[{ id: "1", name: "Alice" }, { id: "2", name: "Bob" }]}
renderItem={(user) => <span>{user.name}</span>}
keyExtractor={(user) => user.id}
/>
// Generic hook
function useArray<T>(initial: T[]) {
const [array, setArray] = useState(initial);
const push = (item: T) => setArray((prev) => [...prev, item]);
return { array, push, setArray };
}Event Types & Refs
React event types are specific: ChangeEvent for inputs, FormEvent for forms, MouseEvent for clicks. Each is generic over the element type (e.target is correctly typed). forwardRef with TypeScript requires two type parameters: the ref type and the props type. forwardRef is needed when a component needs to expose a ref to a DOM element (for focus, measurement, etc.). Always set displayName for forwardRef/memo components for better DevTools debugging. React 19 allows ref as a regular prop, reducing the need for forwardRef, but it's still common in existing code.
// Event types
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
console.log(e.target.value); // string
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
}
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
console.log(e.clientX, e.clientY);
}
// forwardRef with TypeScript
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, ...props }, ref) => (
<label>
{label}
<input ref={ref} {...props} />
</label>
)
);
Input.displayName = "Input";
// Usage: const ref = useRef<HTMLInputElement>(null);
// <Input ref={ref} label="Email" />Utility Types for Props
TypeScript utility types are powerful for prop composition. Pick selects specific props (for subset components). Omit excludes props (for replacing behavior). Partial makes all props optional (for default prop patterns). ComponentProps<typeof Component> extracts a component's prop type — useful for wrapping/extending existing components. Record<K, V> creates a type mapping keys to values (great for variant-to-class maps). These utilities enable DRY, type-safe prop definitions without repeating interfaces. Master them to write maintainable React + TypeScript code.
// Pick: select specific props
interface ButtonProps {
text: string;
onClick: () => void;
color: string;
size: "sm" | "md" | "lg";
}
type IconButtonProps = Pick<ButtonProps, "onClick" | "size"> & {
icon: React.ReactNode;
};
// Omit: exclude specific props
type LinkButtonProps = Omit<ButtonProps, "onClick"> & {
href: string;
};
// Partial: all props optional (for defaults)
type DefaultProps = Partial<ButtonProps>;
// ComponentProps: extract props from existing component
type MyButtonProps = React.ComponentProps<typeof Button> & {
variant?: "custom";
};
// ReturnType: type of a function's return
type User = ReturnType<typeof fetchUser>;
// Record for prop maps
type ButtonVariants = Record<"primary" | "danger" | "ghost", string>;Concurrent Features (useTransition, useDeferredValue)
useTransition
useTransition marks a state update as non-urgent (transition). Urgent updates (input value) render immediately for responsiveness; non-urgent updates (filtering 10,000 items) can be interrupted if the user types again. isPending indicates the transition is in progress (show a subtle loading indicator). This prevents the UI from freezing during expensive renders. The key insight: React can interrupt and discard stale transitions, keeping the UI responsive. Use for search filtering, tab switching, and any state update that triggers heavy rendering. Don't wrap urgent updates (typing, clicking) in transitions.
import { useTransition, useState } from "react";
function SearchResults() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const handleSearch = (value) => {
setQuery(value); // urgent: update input immediately
startTransition(() => {
// non-urgent: heavy filtering can be interrupted
const filtered = heavyFilter(allItems, value);
setResults(filtered);
});
};
return (
<div>
<input value={query} onChange={(e) => handleSearch(e.target.value)} />
{isPending && <span>Updating...</span>}
<ul>{results.map((r) => <li key={r.id}>{r.name}</li>)}</ul>
</div>
);
}useDeferredValue
useDeferredValue is the declarative counterpart to useTransition. It returns a deferred copy of a value that updates with lower priority. The input updates immediately (urgent); the expensive list re-renders with the deferred value (non-urgent). React.memo on the Results component is crucial — it prevents re-rendering on every keystroke, only when deferredQuery changes. isStale (comparing current vs deferred) lets you show a visual indicator (dimmed, spinner). Use useDeferredValue when you can't control the state update (e.g., value comes from props). Use useTransition when you control the update.
function Search() {
const [query, setQuery] = useState("");
// query updates immediately; deferredQuery lags behind
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results query={deferredQuery} isStale={isStale} />
</div>
);
}
// Memoize the expensive component so it only re-renders
// when deferredQuery changes (not on every keystroke)
const Results = React.memo(function Results({ query, isStale }) {
const items = expensiveSearch(query); // heavy computation
return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
{items.map((i) => <div key={i.id}>{i.name}</div>)}
</div>
);
});useOptimistic (React 19)
useOptimistic (React 19) implements optimistic updates — the UI updates immediately with the expected result, then reconciles with the actual server response. The optimistic state shows during the async operation; when the real data arrives (component re-renders with new props), the optimistic value is automatically replaced. If the operation fails, the optimistic update simply reverts on the next render with unchanged props. This eliminates manual optimistic update logic (tracking pending state, reverting on error). Mark pending items (pending: true) to show loading indicators. Perfect for likes, comments, and toggles.
import { useOptimistic } from "react";
function ThumbsUp({ likes, addLike }) {
// Optimistic state: updates immediately, reverts on error
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
(state, newLike) => [...state, newLike]
);
const handleClick = async () => {
const newLike = { id: Date.now(), pending: true };
addOptimisticLike(newLike); // instant UI update
try {
await addLike(newLike); // actual API call
} catch {
// Reverts automatically on re-render with real data
}
};
return (
<div>
<button onClick={handleClick}>👍 {optimisticLikes.length}</button>
{optimisticLikes.some((l) => l.pending) && <span>Saving...</span>}
</div>
);
}use (React 19 Hook)
use() is React 19's new hook that reads context or promises. Unlike useContext, use() can be called conditionally (inside if statements, loops) — it doesn't have the rules-of-hooks restriction. For promises, use() suspends the component until the promise resolves (requires a Suspense boundary). The promise is created in the parent and passed as a prop — this starts fetching during render (not in useEffect), enabling waterfalls to start earlier. The same promise can be passed to multiple components (deduplication). use() bridges the gap between synchronous context and async data.
import { use } from "react";
// Read context with use (works in conditions!)
function Theme() {
// Unlike useContext, use() can be inside conditions
if (showTheme) {
const theme = use(ThemeContext); // conditional context!
return <div style={{ background: theme.color }} />;
}
return null;
}
// Read promises with use (Suspense integration)
function UserProfile({ userPromise }) {
// Suspends until promise resolves
const user = use(userPromise);
return <h1>{user.name}</h1>;
}
// Parent passes promise (starts fetching during render)
function App() {
const userPromise = fetchUser(); // starts immediately
return (
<Suspense fallback={<Loading />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}Concurrent Rendering Patterns
Concurrent patterns: useTransition for non-urgent tab switches (keeps nav responsive while heavy content renders). useSyncExternalStore safely subscribes to external stores (browser APIs, Redux, Zustand) in concurrent mode — it provides a snapshot function for client and server (SSR-safe). Never use external mutable state directly in render (tearing risk); always go through useSyncExternalStore. The three arguments: subscribe (returns cleanup), getSnapshot (current value), getServerSnapshot (SSR initial value). This ensures consistent reads during concurrent rendering. Libraries like Redux and Zustand use this internally.
// Pattern 1: Deferred search with transition
function App() {
const [tab, setTab] = useState("home");
const [isPending, startTransition] = useTransition();
return (
<>
<nav>
<button
onClick={() => startTransition(() => setTab("analytics"))}
disabled={isPending}
>
{isPending ? "Loading..." : "Analytics"}
</button>
</nav>
{tab === "home" && <Home />}
{tab === "analytics" && <HeavyAnalytics />}
</>
);
}
// Pattern 2: useSyncExternalStore for external state
function useOnlineStatus() {
return useSyncExternalStore(
(callback) => {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
},
() => navigator.onLine, // client snapshot
() => true // server snapshot (SSR)
);
}Context API Deep Dive
createContext & Provider
createContext makes a context object with a default value used when no Provider is found. The Provider value prop is consumed by all descendants. Wrap the value in useCallback/useMemo to prevent unnecessary re-renders.
const ThemeContext = React.createContext({ theme: 'light', toggle: () => {} });
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggle = useCallback(() => setTheme((t) => (t === 'light' ? 'dark' : 'light')), []);
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}useContext
useContext reads the nearest Provider value and re-renders the component when that value changes. Nest multiple Providers for different concerns. Split contexts by update frequency for performance.
function ThemedButton() {
const { theme, toggle } = useContext(ThemeContext);
return (
<button onClick={toggle} style={{ background: theme === 'dark' ? '#333' : '#eee' }}>
Toggle Theme
</button>
);
}Context with Reducer
Pairing useReducer with Context creates a global store without Redux. The reducer centralizes state logic; the Context distributes state and dispatch. Consumers can dispatch actions without prop-drilling.
const StoreContext = React.createContext(null);
function storeReducer(state, action) {
switch (action.type) {
case 'add': return { items: [...state.items, action.item] };
case 'remove': return { items: state.items.filter((_, i) => i !== action.index) };
default: return state;
}
}
function StoreProvider({ children }) {
const [state, dispatch] = useReducer(storeReducer, { items: [] });
return <StoreContext.Provider value={{ state, dispatch }}>{children}</StoreContext.Provider>;
}Optimizing Context Renders
When state and dispatch live in the same context, every state change re-renders all consumers. Splitting them means dispatch-only components never re-render on state changes. dispatch from useReducer is stable.
const StateContext = React.createContext(null);
const DispatchContext = React.createContext(null);
function Provider({ children }) {
const [state, dispatch] = useReducer(reducer, initial);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}Custom Hook for Context
Wrapping useContext in a custom hook gives a clean API and a clear error when the Provider is missing. Export both the Provider and the hook. This is the recommended way to consume context.
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}useReducer
Basic useReducer
useReducer is an alternative to useState for complex state logic. The reducer is a pure function: (state, action) => newState. dispatch is stable, so you can pass it down without worrying about re-renders.
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
case 'reset': return { count: 0 };
default: throw new Error('Unknown action: ' + action.type);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return <button onClick={() => dispatch({ type: 'increment' })}>{state.count}</button>;
}Lazy Initialization
The third argument to useReducer is an init function that runs once during the initial render. This is useful when the initial state is expensive to compute or when you want reset to return to a computed state.
function init(initialCount) {
return { count: initialCount, history: [] };
}
function reducer(state, action) {
switch (action.type) {
case 'increment': return { ...state, count: state.count + 1 };
case 'reset': return init(action.payload);
default: return state;
}
}
const [state, dispatch] = useReducer(reducer, initialCount, init);Complex State Shape
useReducer shines when state has multiple related fields. Each action describes a complete state transition, making the logic easier to trace than scattered setState calls. Keep the reducer pure.
const initialState = { users: [], loading: false, error: null, filter: 'all' };
function reducer(state, action) {
switch (action.type) {
case 'fetch-start': return { ...state, loading: true, error: null };
case 'fetch-success': return { ...state, loading: false, users: action.users };
case 'fetch-error': return { ...state, loading: false, error: action.error };
case 'set-filter': return { ...state, filter: action.filter };
default: return state;
}
}Reducer with Context
Combining useReducer with Context creates a lightweight Redux-like store. The reducer holds the logic; the Context distributes state and dispatch. This is the recommended pattern for app-wide state in medium apps.
function todoReducer(state, action) {
switch (action.type) {
case 'add': return [...state, { id: Date.now(), text: action.text, done: false }];
case 'toggle': return state.map((t) => t.id === action.id ? { ...t, done: !t.done } : t);
case 'delete': return state.filter((t) => t.id !== action.id);
default: return state;
}
}
export function TodoProvider({ children }) {
const [todos, dispatch] = useReducer(todoReducer, []);
return <TodoContext.Provider value={{ todos, dispatch }}>{children}</TodoContext.Provider>;
}Action Types & Patterns
Define action types as constants to avoid typos and enable IDE autocomplete. The action shape { type, payload? } is a common convention. For TypeScript, define a discriminated union of action types.
const ACTIONS = { ADD: 'add', UPDATE: 'update', DELETE: 'delete' };
function reducer(state, action) {
switch (action.type) {
case ACTIONS.ADD:
return [...state, { id: action.id, ...action.payload }];
case ACTIONS.UPDATE:
return state.map((item) => item.id === action.id ? { ...item, ...action.payload } : item);
case ACTIONS.DELETE:
return state.filter((item) => item.id !== action.id);
default: return state;
}
}useMemo & useCallback
useMemo
useMemo caches the result of a computation and only recomputes when dependencies change. Use it for expensive calculations (sorting, filtering large arrays). The dependency array must include everything the callback uses.
function ProductList({ products, filter }) {
const filtered = useMemo(() => {
return products.filter((p) => p.category === filter);
}, [products, filter]);
const sorted = useMemo(() => [...filtered].sort((a, b) => a.price - b.price), [filtered]);
return <ul>{sorted.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}useCallback
useCallback memoizes a function so it keeps the same identity across renders unless dependencies change. This is critical when passing callbacks to memoized children — without it, the child re-renders every time.
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => setCount((c) => c + 1), []);
return <MemoizedChild onClick={handleClick} />;
}React.memo
React.memo wraps a component so it only re-renders when its props change (shallow comparison). The second argument is a custom comparator returning true to skip re-rendering. Combine memo with useCallback/useMemo for props.
const ExpensiveItem = React.memo(function ExpensiveItem({ value, onClick }) {
return <li onClick={onClick}>{value}</li>;
});
// With custom comparison
const DeepChild = React.memo(
({ user }) => <div>{user.name}</div>,
(prev, next) => prev.user.id === next.user.id
);When to Memoize
Memoization has a cost that can exceed the savings. Only memoize when: (1) the computation is expensive, (2) the value is passed to a memoized child, or (3) the value is used as a dependency in useEffect/useMemo.
// GOOD: expensive computation
const sorted = useMemo(() => heavySort(data), [data]);
// GOOD: callback passed to memoized child
const onSelect = useCallback((id) => setSelected(id), []);
// BAD: cheap operation, no perf issue
const label = useMemo(() => first + ' ' + last, [first, last]);useMemo for Referential Equality
useMemo ensures objects and arrays keep the same reference across renders, which matters when they are used as dependencies in useEffect/useMemo/useCallback. Without it, { q: query } creates a new object every render.
function Search({ query }) {
const params = useMemo(() => ({ q: query, limit: 10 }), [query]);
useEffect(() => {
api.search(params).then(setData);
}, [params]); // Without useMemo, this fires every render
}Portals & Refs
createPortal
createPortal renders children into a DOM node outside the parent component DOM tree (usually document.body). This is essential for modals, tooltips, and dropdowns that must escape parent stacking contexts.
import { createPortal } from 'react-dom';
function Modal({ open, onClose, children }) {
if (!open) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>{children}</div>
</div>,
document.body
);
}useRef
useRef returns a mutable object whose .current persists across renders without causing re-renders. The primary use is accessing DOM nodes. It also stores mutable values that do not affect the UI.
function FocusInput() {
const inputRef = useRef(null);
const focus = () => inputRef.current?.focus();
return (
<>
<input ref={inputRef} type="text" />
<button onClick={focus}>Focus</button>
</>
);
}forwardRef
forwardRef lets a parent component pass a ref through a wrapper component to a child DOM node. Without it, React forbids passing ref as a prop. The ref is the second argument to the wrapped function.
const FancyInput = React.forwardRef(function FancyInput({ label, ...props }, ref) {
return (
<label>{label}<input ref={ref} {...props} className="fancy-input" /></label>
);
});useImperativeHandle
useImperativeHandle customizes the instance exposed to the parent via ref — instead of the raw DOM node, the parent sees only the methods you define. Use sparingly; prefer declarative props when possible.
const VideoPlayer = React.forwardRef(function VideoPlayer(props, ref) {
const videoRef = useRef(null);
useImperativeHandle(ref, () => ({
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seek: (time) => { if (videoRef.current) videoRef.current.currentTime = time; },
}));
return <video ref={videoRef} src={props.src} />;
});Refs for Mutable Values
useRef stores mutable values that should not trigger re-renders — like timer IDs, WebSocket instances, or "is mounted" flags. Always clean up side effects in the useEffect cleanup function.
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
const start = () => {
if (intervalRef.current) return;
intervalRef.current = setInterval(() => setSeconds((s) => s + 1), 1000);
};
const stop = () => { clearInterval(intervalRef.current); intervalRef.current = null; };
useEffect(() => () => clearInterval(intervalRef.current), []);
return <>{seconds}<button onClick={start}>Start</button><button onClick={stop}>Stop</button></>;
}Error Boundaries
Class Error Boundary
Error boundaries are class components that catch errors in their child component tree during rendering. getDerivedStateFromError updates state to render the fallback; componentDidCatch logs the error. They do NOT catch errors in event handlers or async code.
class ErrorBoundary extends React.Component {
constructor(props) { super(props); this.state = { hasError: false, error: null }; }
static getDerivedStateFromError(error) { return { hasError: true, error }; }
componentDidCatch(error, info) { console.error('Caught:', error, info); }
render() {
if (this.state.hasError) return this.props.fallback || <h1>Something went wrong.</h1>;
return this.props.children;
}
}Using Error Boundaries
Place error boundaries strategically so a crash in one part of the UI does not take down the whole app. Granular boundaries around widgets let the rest of the app keep working.
function App() {
return (
<ErrorBoundary fallback={<ErrorPage />}>
<Header />
<ErrorBoundary fallback={<SidebarCrash />}><Sidebar /></ErrorBoundary>
<ErrorBoundary fallback={<ContentCrash />}><MainContent /></ErrorBoundary>
</ErrorBoundary>
);
}Resetting Error State
Error boundaries stay in the error state until their state changes. Provide a "Try again" button that resets hasError to false, letting React re-render the children. Changing the key prop also resets it.
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) { return { hasError: true, error }; }
reset = () => this.setState({ hasError: false, error: null });
render() {
if (this.state.hasError) return <div><p>Failed.</p><button onClick={this.reset}>Try again</button></div>;
return this.props.children;
}
}react-error-boundary Library
The react-error-boundary library provides a polished, hook-friendly error boundary without writing a class. FallbackComponent receives the error and a resetErrorBoundary function. resetKeys auto-resets when those values change.
import { ErrorBoundary } from 'react-error-boundary';
<ErrorBoundary
FallbackComponent={ErrorFallback}
onError={(error, info) => logError(error, info)}
onReset={() => window.location.reload()}
resetKeys={[location.pathname]}
>
<Routes />
</ErrorBoundary>Async Error Handling
Error boundaries do not catch errors in promises, setTimeout, or event handlers. To surface async errors to a boundary, store the error in state and re-throw it during render. The boundary then catches it.
function AsyncComponent() {
const [state, setState] = useState({ data: null, error: null });
useEffect(() => {
let active = true;
fetchData()
.then((data) => { if (active) setState({ data, error: null }); })
.catch((error) => { if (active) setState({ data: null, error }); });
return () => { active = false; };
}, []);
if (state.error) throw state.error;
if (!state.data) return <Loading />;
return <div>{state.data}</div>;
}Performance Optimization
Virtualization
Virtualization renders only the visible rows of a long list, dramatically reducing DOM nodes. Essential for lists over 1000 items — without it, the browser chokes on tens of thousands of nodes.
import { useVirtualizer } from '@tanstack/react-virtual';
function BigList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((vi) => (
<div key={vi.key} style={{ position: 'absolute', top: vi.start, height: vi.size }}>
{items[vi.index].name}
</div>
))}
</div>
</div>
);
}Code Splitting
Code splitting breaks the bundle into chunks loaded on demand. The most impactful splits are route-level (each page is a separate chunk) and heavy widgets (chart libraries, editors). Measure with bundle analyzers.
import { lazy, Suspense } from 'react';
const Admin = lazy(() => import('./Admin'));
const Chart = lazy(() => import('./Chart'));
function Page({ showChart }) {
return (
<Suspense fallback={<Skeleton />}>
{showChart && <Chart data={data} />}
</Suspense>
);
}Profiling with DevTools
The React DevTools Profiler records render times and shows which components re-rendered and why. Look for "wasted" renders where props did not actually change — those are candidates for React.memo.
// Use React DevTools Profiler to record renders
// Look for:
// - Components rendering too often
// - Long commit phases
// - Wasted renders (props didn't change)
// Wrap expensive renders to find bottlenecks
function MyComponent({ data }) {
console.time('render');
const result = heavyCompute(data);
console.timeEnd('render');
return <div>{result}</div>;
}useDeferredValue
useDeferredValue delays updating a value, letting urgent updates (typing) happen first. The expensive render uses the deferred value, so it does not block the input. isStale lets you show a subtle visual hint.
function Search({ query }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const results = useMemo(() => expensiveSearch(deferredQuery), [deferredQuery]);
return <div style={{ opacity: isStale ? 0.7 : 1 }}>{results.map((r) => <div key={r.id}>{r.name}</div>)}</div>;
}Concurrent Features
React 18 concurrent features keep the UI responsive during heavy work. useTransition and useDeferredValue let React interrupt renders to handle urgent input. Automatic batching groups multiple setState calls into one re-render.
import { useTransition, useDeferredValue } from 'react';
// useTransition: mark updates as non-urgent
const [isPending, startTransition] = useTransition();
const filterResults = (q) => startTransition(() => setResults(search(q)));
// Automatic batching (React 18): state updates in promises batch automatically
fetch('/api').then(() => {
setLoading(false); // \
setData(data); // > single re-render
setError(null); // /
});Testing (React Testing Library)
Basic Render & Query
render() mounts a component in a fake DOM; screen queries it. Prefer queries by role (getByRole) — they mirror how assistive tech sees the page and enforce accessibility. userEvent simulates real user interactions.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('increments on click', async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
const button = screen.getByRole('button', { name: /increment/i });
await user.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});Query Variants
getBy asserts the element exists (throws otherwise). queryBy is for asserting absence (returns null). findBy waits for async elements to appear. The All variants handle multiple matches.
// getBy: throws if 0 or >1 matches (strict)
screen.getByRole('button', { name: 'Submit' });
// queryBy: returns null if 0 matches (for assertions of absence)
expect(screen.queryByText('Error')).not.toBeInTheDocument();
// findBy: returns a Promise, waits for match (async)
const element = await screen.findByText('Loaded');
// getAllBy: returns array (multiple matches)
const items = screen.getAllByRole('listitem');Firing Events
userEvent (not the lower-level fireEvent) is the recommended way to simulate interactions — it triggers all the events a real user would (focus, input, keydown, click) in the right order. Always use await with userEvent methods.
import userEvent from '@testing-library/user-event';
test('form submission', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<Form onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(onSubmit).toHaveBeenCalled();
});waitFor & Async
waitFor polls until the assertion passes or times out. findBy* combines waitFor and getBy for the common case of "wait for this to appear." within scopes queries to a specific element.
import { waitFor, within } from '@testing-library/react';
test('shows data after fetch', async () => {
render(<UserList />);
await waitFor(() => {
const list = screen.getByRole('list');
expect(within(list).getAllByRole('listitem')).toHaveLength(3);
});
});
// findBy is often cleaner than waitFor + getBy
test('shows data (cleaner)', async () => {
render(<UserList />);
expect(await screen.findAllByRole('listitem')).toHaveLength(3);
});Mocking & Setup
MSW (Mock Service Worker) intercepts network requests at the service-worker level, so your fetch code runs unmodified. Set up handlers per test, reset between tests, and close after all.
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/user', (req, res, ctx) => res(ctx.json({ name: 'Alice' })))
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('shows user name', async () => {
render(<UserProfile />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});Related React snippets
Copy-paste ready code for common tasks.
Controlled Form with Validation
Build a controlled React form with inline validation and error messages.
Event Handling and List Rendering
Handle events and render dynamic lists with keys in React.
useState
State management Hook.
useEffect
Side-effect Hook.
useContext
Shared state via context.
useReducer
Complex state management.
useMemo
Memoize computation results.
useCallback
Memoize callback functions.
useRef
Reference DOM and mutable values.
Custom Hooks
Extract reusable logic.
Component Communication
Parent-child and sibling component communication.
Error Boundaries
Catch component errors.
Lazy Loading
Code splitting and lazy loading.
Portal
Render to DOM nodes outside the component.
Higher-Order Components
Component enhancement pattern.
Render Props
Render props pattern.
Performance Optimization
React performance optimization tips.
Was this helpful?