Skip to main content

React Best Practices

Overview

React is our primary frontend framework for web applications. This guide covers modern React patterns (React 19+) with TypeScript, focusing on functional components, hooks, and performance optimization. For general frontend principles like component-driven development, separation of concerns, and project structure, see Web Overview.

React's declarative programming model allows you to describe what the UI should look like for any given state, rather than imperatively manipulating the DOM. This approach reduces bugs, improves code maintainability, and enables powerful optimization techniques through React's reconciliation algorithm (the virtual DOM diffing process).

Why React 19+? React 19 builds on concurrent rendering features introduced in React 18 that allow React to prepare multiple versions of the UI simultaneously. This enables features like automatic batching (grouping multiple state updates into a single re-render) and transitions (marking certain updates as non-urgent). These features are particularly valuable when building responsive UIs that handle high-frequency updates or large datasets.


Core Principles

1. Functional Components Only

Modern React development exclusively uses functional components with hooks. Class components were the original component model but are now considered legacy. Functional components are simpler, have less boilerplate, and integrate seamlessly with hooks.

Why functional components?

  • Simpler mental model: Functions are easier to understand than classes with lifecycle methods
  • Better code reuse: Custom hooks extract logic more cleanly than higher-order components or render props
  • Better performance: Smaller bundle size and easier for React to optimize
  • Future-proof: All new React features target functional components

2. TypeScript Always

TypeScript provides static type checking that catches errors at compile-time rather than runtime. This is especially critical in applications where data flows through multiple layers (API -> state -> UI).

Key benefits:

  • Type safety for props: Prevents passing incorrect prop types to components
  • Autocomplete: IDEs provide intelligent suggestions based on types
  • Refactoring confidence: Renaming properties automatically updates all usages
  • Self-documenting code: Types serve as inline documentation

3. Component Composition

Build complex UIs by composing small, focused components rather than creating monolithic components. React's component model is designed for composition - components receive props and render other components, building tree structures that are easy to understand and modify. See Web Components for general component patterns.

4. Immutable State

Never mutate state directly. Always create new objects/arrays when updating state. React's reconciliation algorithm relies on reference equality checks to determine what changed.

Why immutability matters:

  • Predictable updates: React knows when to re-render based on reference changes
  • Time-travel debugging: Previous states can be preserved for debugging
  • Performance optimizations: Shallow equality checks are fast (React.memo, useMemo)

5. Separation of Concerns

Separate UI rendering logic from business logic and data fetching. In React, this means container components handle data fetching and state management while presentational components focus on rendering UI from props. Custom hooks encapsulate reusable stateful logic. See Web Overview for the container/presentational pattern in detail.

6. Performance by Default

Optimize proactively, not reactively. Applications must remain responsive even when handling large datasets or complex interactions. See React Performance for detailed optimization strategies.


Component Structure

Basic Functional Component

// PaymentCard.tsx
import { FC } from 'react';

interface PaymentCardProps {
id: string;
amount: number;
currency: string;
status: 'pending' | 'completed' | 'failed';
onView: (id: string) => void;
}

export const PaymentCard: FC<PaymentCardProps> = ({
id,
amount,
currency,
status,
onView
}) => {
const statusColor = {
pending: 'yellow',
completed: 'green',
failed: 'red'
}[status];

return (
<div className="payment-card">
<div className="payment-card__header">
<span className="payment-card__id">{id}</span>
<span
className="payment-card__status"
style={{ backgroundColor: statusColor }}
>
{status}
</span>
</div>
<div className="payment-card__amount">
{amount.toFixed(2)} {currency}
</div>
<button onClick={() => onView(id)}>View Details</button>
</div>
);
};

File Structure

src/
|-- components/
| |-- PaymentCard/
| | |-- PaymentCard.tsx
| | |-- PaymentCard.module.css
| | |-- PaymentCard.test.tsx
| | `-- index.ts # Re-export
| `-- index.ts # Barrel export
|-- features/
| `-- payments/
| |-- components/ # Feature-specific components
| |-- hooks/ # Feature-specific hooks
| |-- api/ # API client
| `-- types/ # Feature types
|-- hooks/ # Shared custom hooks
|-- services/ # Business logic
|-- stores/ # Zustand stores
`-- types/ # Shared types

React Hooks

Hooks are functions that let you "hook into" React features from function components. They allow you to use state, lifecycle methods, context, and other React features without writing classes.

Hook Rules (enforced by eslint-plugin-react-hooks):

  1. Only call hooks at the top level: Never inside loops, conditions, or nested functions. This ensures hooks are called in the same order each render, which is how React tracks hook state.
  2. Only call hooks from React functions: Call from functional components or custom hooks, not regular JavaScript functions.

useState

useState manages local component state. It returns a pair: the current state value and a function to update it. Unlike class component setState, useState does not automatically merge updates - you must spread existing state when updating objects.

How it works internally: React maintains a queue of state updates. When you call the setter function, React schedules a re-render and applies the update. Multiple updates in the same render cycle are batched together (React automatic batching).

import { useState } from 'react';

function PaymentForm() {
const [amount, setAmount] = useState<number>(0);
const [currency, setCurrency] = useState<string>('USD');

// GOOD: Functional update for state depending on previous value
// Functional updates receive the most current state, even if multiple updates are queued
const incrementAmount = () => {
setAmount(prev => prev + 10);
};

// BAD: Direct state mutation
// This bypasses React's state management and won't trigger re-renders
const badUpdate = () => {
amount = amount + 10; // Never do this!
};

// GOOD: For objects, spread existing state to preserve other properties
const [formData, setFormData] = useState({ amount: 0, currency: 'USD' });
const updateAmount = (newAmount: number) => {
setFormData(prev => ({ ...prev, amount: newAmount }));
};

return (
<form>
<input
type="number"
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
/>
<select value={currency} onChange={(e) => setCurrency(e.target.value)}>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
</form>
);
}

When to use functional updates: Use the function form setState(prev => ...) whenever the new state depends on the previous state. React automatic batching groups multiple state updates into a single re-render. If you call setState multiple times with the current state value, later updates might use stale values. The function form receives the most current state, ensuring correct updates even when batched.

useEffect

useEffect performs side effects in function components. Side effects include data fetching, subscriptions, timers, or manually changing the DOM. It serves the same purpose as componentDidMount, componentDidUpdate, and componentWillUnmount combined in class components.

How it works: After React renders the component and updates the DOM, it runs your effect function. The dependency array tells React when to re-run the effect - only when those dependencies change. This prevents unnecessary effect executions.

Cleanup functions are critical for preventing memory leaks. Return a cleanup function from your effect to cancel subscriptions, clear timers, or abort fetch requests when the component unmounts or before the effect runs again.

import { useEffect, useState } from 'react';

function PaymentDetails({ paymentId }: { paymentId: string }) {
const [payment, setPayment] = useState<Payment | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
const controller = new AbortController();

async function fetchPayment() {
setLoading(true);
try {
const response = await fetch(`/api/payments/${paymentId}`, {
signal: controller.signal
});
const data = await response.json();
setPayment(data);
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.error('Failed to fetch payment:', error);
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}

fetchPayment();

// Cleanup function cancels in-flight request on unmount or dependency change
return () => {
controller.abort();
};
}, [paymentId]); // Re-run when paymentId changes

if (loading) return <div>Loading...</div>;
if (!payment) return <div>Payment not found</div>;

return <div>{payment.id}: {payment.amount}</div>;
}
useEffect Best Practices
  1. Always include cleanup functions for async operations, subscriptions, timers
  2. List all dependencies in the dependency array - React's exhaustive-deps lint rule helps
  3. Don't perform state updates during render - only in effects, event handlers, or callbacks
  4. Consider React Query for data fetching - it handles caching, loading states, and errors better

Dependency array behavior:

  • No array useEffect(() => {}): Runs after every render (rarely needed)
  • Empty array useEffect(() => {}, []): Runs once on mount only
  • With dependencies useEffect(() => {}, [dep1, dep2]): Runs when dependencies change

Common mistake: Omitting dependencies. This causes the effect to reference stale values from previous renders. Always include all variables from component scope that the effect uses.

React 19 Form and Mutation Patterns

React 19 introduces ergonomics for async UI mutations (useActionState, useOptimistic) that reduce manual loading/error wiring in form-heavy flows. Use these patterns for user-triggered mutations, while keeping server-state fetching/caching in React Query.

Practical split:

  • React Query: canonical server-state cache, background refetch, deduplication.
  • useActionState/useOptimistic: mutation UX details at component boundary (pending state, optimistic rows, inline error).

useCallback

useCallback memoizes callback functions, returning the same function reference across re-renders unless dependencies change. React.memo components use reference equality to determine if props changed. When you pass a new function reference (created on each render), the memoized component re-renders even if the function's behavior is identical. useCallback prevents this by maintaining the same function reference.

When to use:

  • Passing callbacks to memoized child components (React.memo)
  • Callbacks used in dependency arrays of other hooks
  • Callbacks passed to context that many components consume

When NOT to use: Don't wrap every callback in useCallback. It has overhead (memory and computation). Only use when you're actually preventing re-renders or when the callback is a dependency of other hooks.

import { useCallback, useState } from 'react';

function PaymentList() {
const [payments, setPayments] = useState<Payment[]>([]);

// GOOD: Memoize callback to prevent child re-renders
const handlePaymentView = useCallback((id: string) => {
console.log('Viewing payment:', id);
// Navigate or open modal
}, []); // Empty deps - function never changes

// GOOD: Callback with dependencies
const handlePaymentDelete = useCallback((id: string) => {
setPayments(prev => prev.filter(p => p.id !== id));
}, []); // setPayments is stable, no deps needed

return (
<div>
{payments.map(payment => (
<PaymentCard
key={payment.id}
{...payment}
onView={handlePaymentView}
onDelete={handlePaymentDelete}
/>
))}
</div>
);
}

useMemo

import { useMemo } from 'react';

function PaymentSummary({ payments }: { payments: Payment[] }) {
// GOOD: Expensive computation memoized
const statistics = useMemo(() => {
return {
total: payments.reduce((sum, p) => sum + p.amount, 0),
count: payments.length,
avgAmount: payments.length > 0
? payments.reduce((sum, p) => sum + p.amount, 0) / payments.length
: 0,
byStatus: payments.reduce((acc, p) => {
acc[p.status] = (acc[p.status] || 0) + 1;
return acc;
}, {} as Record<string, number>)
};
}, [payments]); // Recalculate only when payments change

return (
<div>
<div>Total: ${statistics.total.toFixed(2)}</div>
<div>Count: {statistics.count}</div>
<div>Average: ${statistics.avgAmount.toFixed(2)}</div>
</div>
);
}
Don't Overuse useMemo

Only use useMemo for:

  • Expensive calculations: Large array operations, complex filtering/sorting, recursive computations
  • Referential stability: Objects/arrays passed to memoized components or dependency arrays
  • Performance bottlenecks: Identified through profiling

React is fast enough for most calculations without memoization. Premature optimization adds complexity and memory overhead. Profile first, optimize second.

How useMemo works: On initial render, useMemo executes the computation and caches the result. On subsequent renders, if dependencies haven't changed (determined by Object.is comparison), it returns the cached value. If dependencies changed, it recomputes.

Cost-benefit analysis: useMemo has costs (memory for cached value, comparison of dependencies). Only use when the computation cost exceeds the memoization cost. For simple operations like arithmetic or string concatenation, memoization is overkill.

useReducer

useReducer is an alternative to useState for managing complex state logic. It's especially useful when:

  • State has multiple sub-values that are updated together
  • Next state depends on previous state in complex ways
  • You want to centralize state update logic for testing/maintainability

When to choose useReducer over useState:

  • Multiple related state values (user profile with name, email, preferences)
  • Complex state transitions (multi-step workflows, state machines)
  • State logic shared across components (extract reducer, use in multiple places)

useReducer vs. Redux/Zustand: useReducer is local to the component. For global state management, use Zustand. Don't use useReducer + Context as a Redux replacement - it causes performance issues since all context consumers re-render on any state change.

import { useReducer } from 'react';

interface PaymentState {
payments: Payment[];
selectedId: string | null;
filter: 'all' | 'pending' | 'completed';
}

type PaymentAction =
| { type: 'ADD_PAYMENT'; payload: Payment }
| { type: 'REMOVE_PAYMENT'; payload: string }
| { type: 'SELECT_PAYMENT'; payload: string }
| { type: 'SET_FILTER'; payload: PaymentState['filter'] };

function paymentReducer(state: PaymentState, action: PaymentAction): PaymentState {
switch (action.type) {
case 'ADD_PAYMENT':
return {
...state,
payments: [...state.payments, action.payload]
};
case 'REMOVE_PAYMENT':
return {
...state,
payments: state.payments.filter(p => p.id !== action.payload),
selectedId: state.selectedId === action.payload ? null : state.selectedId
};
case 'SELECT_PAYMENT':
return {
...state,
selectedId: action.payload
};
case 'SET_FILTER':
return {
...state,
filter: action.payload
};
default:
return state;
}
}

function PaymentManager() {
const [state, dispatch] = useReducer(paymentReducer, {
payments: [],
selectedId: null,
filter: 'all'
});

return (
<div>
<button onClick={() => dispatch({ type: 'SET_FILTER', payload: 'pending' })}>
Show Pending
</button>
{/* ... */}
</div>
);
}

Component Lifecycle and Rendering

React's rendering process determines when and how components update. Components re-render when state changes, props change, or a parent component re-renders. React's reconciliation algorithm (virtual DOM diffing) efficiently determines which actual DOM nodes need updates by comparing the new component tree to the previous one.

Rendering phases:

  1. Render phase (pure, can be interrupted): React calls your component function, executes hooks, and builds a tree of React elements
  2. Commit phase (cannot be interrupted): React updates the DOM to match the render result
  3. useLayoutEffect execution: Runs synchronously after DOM updates but before browser paint - use for DOM measurements
  4. Browser paint: Browser displays updates on screen
  5. useEffect execution: Runs asynchronously after paint - use for side effects that don't affect layout

Key insight: Component functions must be pure during the render phase. Don't modify state, make API calls, or perform side effects during render - only in useEffect or event handlers.


Custom Hooks

Custom hooks are functions that encapsulate reusable stateful logic. They allow you to extract component logic into reusable functions while still following the Rules of Hooks.

Why custom hooks?

  • Logic reuse: Share stateful logic between components without render props or HOCs
  • Separation of concerns: Extract complex logic from components, keeping them focused on rendering
  • Testability: Test hooks independently from UI components
  • Composition: Combine multiple hooks to create more complex behaviors

Naming convention: Always prefix custom hooks with "use" (e.g., usePayment, useAuth). This tells React's linter that hook rules apply.

Reusable Logic

Custom hooks can manage their own state, effects, and even use other hooks. They return whatever the component needs - values, functions, or both.

// hooks/usePayment.ts
import { useState, useEffect } from 'react';

interface UsePaymentResult {
payment: Payment | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}

export function usePayment(paymentId: string): UsePaymentResult {
const [payment, setPayment] = useState<Payment | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [refetchKey, setRefetchKey] = useState(0);

useEffect(() => {
let cancelled = false;

async function fetchPayment() {
setLoading(true);
setError(null);

try {
const response = await fetch(`/api/payments/${paymentId}`);
if (!response.ok) throw new Error('Failed to fetch payment');

const data = await response.json();
if (!cancelled) {
setPayment(data);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err : new Error('Unknown error'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}

fetchPayment();

return () => {
cancelled = true;
};
}, [paymentId, refetchKey]);

const refetch = () => setRefetchKey(prev => prev + 1);

return { payment, loading, error, refetch };
}

// Usage
function PaymentDetails({ id }: { id: string }) {
const { payment, loading, error, refetch } = usePayment(id);

if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!payment) return <div>Not found</div>;

return (
<div>
<h1>{payment.id}</h1>
<button onClick={refetch}>Refresh</button>
</div>
);
}

Best practices for custom hooks:

  • Return objects for multiple values (easier to extend than arrays)
  • Keep hooks focused on a single concern
  • Document expected behavior and edge cases
  • Handle cleanup for subscriptions/timers
  • Make dependencies explicit in the API

See React State Management for Zustand and React Query patterns, and React Testing for testing custom hooks with renderHook.


Component Patterns

Component patterns solve common UI challenges through proven architectural approaches. Each pattern has specific use cases where it excels.

Compound Components

The compound component pattern allows you to create components that work together to form a complete UI while sharing implicit state. This pattern is used by libraries like Reach UI and Radix UI.

Benefits:

  • Flexible API: Users can arrange sub-components however they want
  • Implicit state sharing: Parent manages state, children consume it via Context
  • Encapsulation: Implementation details hidden from consumers
  • Semantic JSX: Clear relationship between components

When to use: When building complex, stateful components with multiple related sub-parts (tabs, accordions, dropdowns, wizards). Not needed for simple, single-purpose components.

interface TabsProps {
children: React.ReactNode;
defaultValue?: string;
}

interface TabsContextValue {
activeTab: string;
setActiveTab: (tab: string) => void;
}

const TabsContext = React.createContext<TabsContextValue | null>(null);

export function Tabs({ children, defaultValue = '' }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultValue);

return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}

function TabList({ children }: { children: React.ReactNode }) {
return <div className="tabs__list">{children}</div>;
}

function Tab({ value, children }: { value: string; children: React.ReactNode }) {
const context = React.useContext(TabsContext);
if (!context) throw new Error('Tab must be used within Tabs');

const { activeTab, setActiveTab } = context;
const isActive = activeTab === value;

return (
<button
className={`tabs__tab ${isActive ? 'tabs__tab--active' : ''}`}
onClick={() => setActiveTab(value)}
>
{children}
</button>
);
}

function TabPanel({ value, children }: { value: string; children: React.ReactNode }) {
const context = React.useContext(TabsContext);
if (!context) throw new Error('TabPanel must be used within Tabs');

if (context.activeTab !== value) return null;

return <div className="tabs__panel">{children}</div>;
}

Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

// Usage
<Tabs defaultValue="pending">
<Tabs.List>
<Tabs.Tab value="pending">Pending</Tabs.Tab>
<Tabs.Tab value="completed">Completed</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="pending">
<PendingPayments />
</Tabs.Panel>
<Tabs.Panel value="completed">
<CompletedPayments />
</Tabs.Panel>
</Tabs>

Render Props

interface DataFetcherProps<T> {
url: string;
children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode;
}

function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);

useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);

return <>{children(data, loading, error)}</>;
}

// Usage
<DataFetcher<Payment[]> url="/api/payments">
{(payments, loading, error) => {
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return payments?.map(p => <PaymentCard key={p.id} {...p} />);
}}
</DataFetcher>

Polymorphic Components with Discriminated Unions

Discriminated unions create type-safe components where allowed props change based on a variant field. TypeScript enforces that incompatible props cannot be used together.

When to use:

  • Components that render different elements (button vs anchor)
  • Mutually exclusive prop combinations
  • Type-safe polymorphic components
type ButtonProps = {
children: React.ReactNode;
className?: string;
} & (
| {
variant: 'link';
href: string;
target?: string;
rel?: string;
onClick?: never; // Cannot have onClick with link
}
| {
variant: 'button';
onClick: () => void;
type?: 'button' | 'submit' | 'reset';
href?: never; // Cannot have href with button
}
);

export function Button(props: ButtonProps) {
const { children, className } = props;

if (props.variant === 'link') {
// TypeScript knows href exists here
return (
<a
href={props.href}
target={props.target}
rel={props.rel}
className={className}
>
{children}
</a>
);
}

// TypeScript knows onClick exists here
return (
<button
type={props.type || 'button'}
onClick={props.onClick}
className={className}
>
{children}
</button>
);
}

// Usage
<Button variant="link" href="/dashboard">Go to Dashboard</Button>
<Button variant="button" onClick={handleSubmit}>Submit</Button>

// TypeScript error: onClick not allowed for link variant
<Button variant="link" href="/home" onClick={handleClick}>Home</Button>

Benefits:

  • TypeScript prevents invalid prop combinations at compile time
  • IDE autocomplete shows only valid props for the selected variant
  • Self-documenting API - the type tells you what's allowed

See TypeScript Types for more discriminated union patterns.

Props Spreading for Wrapper Components

Props spreading forwards native element attributes to wrapper components, allowing them to accept all standard HTML attributes while adding custom functionality.

When to use:

  • Wrapper components for native elements (inputs, buttons, links)
  • Design system components that extend HTML elements
  • Higher-order form controls with labels, errors, validation
import { InputHTMLAttributes } from 'react';

interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}

export function TextInput({ label, error, className, ...inputProps }: TextInputProps) {
return (
<div className="form-group">
<label htmlFor={inputProps.id}>{label}</label>
<input
{...inputProps} // Spreads all remaining props to input
className={`input ${error ? 'input--error' : ''} ${className || ''}`}
aria-invalid={!!error}
aria-describedby={error ? `${inputProps.id}-error` : undefined}
/>
{error && (
<div id={`${inputProps.id}-error`} className="error" role="alert">
{error}
</div>
)}
</div>
);
}

// Usage - all native input attributes work
<TextInput
id="amount"
label="Amount"
error={validationError}
type="number"
placeholder="Enter amount"
required
min={0}
step={0.01}
onChange={handleChange}
/>

Key points:

  • Use extends InputHTMLAttributes<HTMLInputElement> to inherit all native props
  • Destructure custom props first, then spread ...rest to the native element
  • Merge className carefully to preserve both custom and consumer classes
  • Forward accessibility attributes (aria-*, id) to maintain accessibility

This pattern enables custom components that feel like native elements while adding design system features.


Performance Optimization

Performance optimization in React primarily focuses on reducing unnecessary re-renders. React's default behavior is to re-render a component whenever its parent re-renders, even if props haven't changed.

React.memo

React.memo is a higher-order component that memoizes the rendered output. If props haven't changed (determined by shallow equality check), React reuses the previous render result instead of calling the component function again.

How it works:

  1. React compares new props to previous props using shallow equality (Object.is)
  2. If all props are equal, React skips rendering and reuses the previous result
  3. If any prop changed, React renders normally

When to use:

  • Component is expensive to render (complex calculations, large lists)
  • Component receives same props frequently (static data, stable callbacks)
  • Component is rendered often but props rarely change

When NOT to use: Don't wrap every component in React.memo. It has overhead (prop comparison on every render). Only use when profiling shows unnecessary re-renders.

import { memo } from 'react';

// GOOD: Memoize expensive components
export const PaymentCard = memo<PaymentCardProps>(({ id, amount, currency, onView }) => {
console.log('Rendering PaymentCard:', id);

return (
<div>
<span>{id}</span>
<span>{amount} {currency}</span>
<button onClick={() => onView(id)}>View</button>
</div>
);
});

// Custom comparison function for complex equality checks
export const ComplexPaymentCard = memo<PaymentCardProps>(
({ payment, onView }) => {
return <div>{/* Complex rendering */}</div>;
},
(prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
// Custom comparison is useful for deep object comparisons
return prevProps.payment.id === nextProps.payment.id &&
prevProps.payment.amount === nextProps.payment.amount;
}
);

Common pitfall: Passing inline objects/arrays as props to memoized components defeats memoization because they're new references every render. Use useMemo for stable references.

//  BAD: New object every render, memo doesn't help
<PaymentCard payment={{ id: '1', amount: 100 }} />

// GOOD: Stable reference
const payment = useMemo(() => ({ id: '1', amount: 100 }), []);
<PaymentCard payment={payment} />

See React Performance for comprehensive optimization strategies including code splitting, virtualization, and bundle optimization.


Error Handling

Error handling in React involves both error boundaries (for render errors) and try-catch blocks (for async errors in event handlers).

Error Boundaries

Error boundaries are class components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI. They catch errors during:

  • Rendering
  • Lifecycle methods
  • Constructors of child components

They do NOT catch errors in:

  • Event handlers (use try-catch)
  • Asynchronous code (setTimeout, requestAnimationFrame)
  • Server-side rendering
  • Errors thrown in the error boundary itself

Why class components? React doesn't yet provide a hook-based error boundary API. You must use class components for error boundaries.

import { Component, ReactNode } from 'react';

interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}

interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}

export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}

static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
// Log to error tracking service (e.g., Sentry)
}

render() {
if (this.state.hasError) {
return this.props.fallback || (
<div>
<h1>Something went wrong</h1>
<p>{this.state.error?.message}</p>
</div>
);
}

return this.props.children;
}
}

// Usage: Wrap sections of your app that might fail
<ErrorBoundary fallback={<ErrorFallback />}>
<PaymentDashboard />
</ErrorBoundary>

// Use multiple boundaries to isolate failures
function App() {
return (
<div>
<ErrorBoundary fallback={<div>Navigation failed to load</div>}>
<Navigation />
</ErrorBoundary>

<ErrorBoundary fallback={<div>Dashboard failed to load</div>}>
<PaymentDashboard />
</ErrorBoundary>
</div>
);
}

Best practices:

  • Granular boundaries: Use multiple error boundaries to isolate failures. Don't wrap the entire app in one boundary.
  • Log errors: Use componentDidCatch to send errors to monitoring services (Sentry, DataDog, etc.)
  • Provide recovery: Include a "retry" button in fallback UI when appropriate
  • Different fallbacks for different contexts: Critical sections might show detailed errors; secondary features might gracefully hide

Event handler error handling:

Error boundaries don't catch errors in event handlers. Use try-catch blocks:

function PaymentForm() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

try {
await createPayment(formData);
showSuccess();
} catch (error) {
// Handle error in event handler
console.error('Payment failed:', error);
showError(error.message);
}
};

return <form onSubmit={handleSubmit}>...</form>;
}

Further Reading

React Framework Guidelines

Cross-Cutting Guidelines

External Resources


Summary

Key Takeaways

  1. Functional components only with hooks; no class components in new code
  2. TypeScript for all components with explicit prop types and return types
  3. Custom hooks extract reusable logic; keep components focused on rendering
  4. useCallback and useMemo prevent unnecessary re-renders in child components
  5. useReducer for complex state with multiple related state values
  6. Component composition builds complex UIs from simple, focused components
  7. Error boundaries catch errors and prevent full app crashes
  8. React.memo optimizes expensive components; use sparingly
  9. Clean up effects to prevent memory leaks and state updates after unmount
  10. Separate concerns - UI logic, business logic, and data fetching in different layers

Next Steps: Review React State Management for Zustand and React Query patterns, and React Testing for component testing strategies.