Doing React the Right Way - 7/10
Advanced React Patterns and Performance Optimization
You’ve mastered React’s fundamentals: components, state, effects, and context. You can build functional applications that handle complex data flow and user interactions. Now it’s time to explore the advanced patterns and techniques that separate hobbyist projects from production-ready applications.
Today we’ll cover error boundaries for resilient applications, custom hooks for reusable logic, performance optimization techniques, code splitting for efficient loading, and portals for flexible DOM rendering. These patterns address the challenges that emerge when applications grow beyond simple demos to serve real users at scale.
Error Boundaries: Building Resilient Applications
In a typical React application, when any component throws an error during rendering, the entire application unmounts, leaving users with a blank white screen. This is unacceptable for production applications—it’s like having your car’s engine fail because the radio stopped working.
Error Boundaries are React components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of crashing the entire application.
Implementing Error Boundaries
Error boundaries must be class components (one of the few remaining use cases for them):
import React, { Component, ErrorInfo, ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
// This lifecycle method catches errors during rendering
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
// This lifecycle method is for side effects like error logging
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Error boundary caught an error:", error, errorInfo);
// Call optional error handler
this.props.onError?.(error, errorInfo);
// In production, you'd send this to an error reporting service
// reportErrorToService(error, errorInfo);
}
render() {
if (this.state.hasError) {
// Render fallback UI
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="error-boundary">
<h2>Something went wrong</h2>
<details style={{ whiteSpace: "pre-wrap" }}>
<summary>Error details</summary>
{this.state.error?.toString()}
</details>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
Strategic Error Boundary Placement
Place error boundaries around logical sections of your application to contain failures:
function App() {
return (
<div className="app">
{/* Header errors won't affect main content */}
<ErrorBoundary fallback={<div>Header unavailable</div>}>
<Header />
</ErrorBoundary>
<main className="main-content">
{/* Separate boundaries for independent features */}
<ErrorBoundary fallback={<div>User profile unavailable</div>}>
<UserProfile />
</ErrorBoundary>
<ErrorBoundary fallback={<div>Dashboard unavailable</div>}>
<Dashboard />
</ErrorBoundary>
</main>
{/* Footer errors won't affect other components */}
<ErrorBoundary fallback={<div>Footer unavailable</div>}>
<Footer />
</ErrorBoundary>
</div>
);
}
This approach ensures that if one section fails, the rest of your application remains functional and accessible to users.
Custom Hooks: Reusable Logic Patterns
As applications grow, you’ll notice repeated patterns across components: data fetching with loading states, form validation, local storage synchronization, and more. Custom hooks let you extract this logic into reusable, testable functions.
The useDebounce Hook
A common pattern is delaying API calls until users stop typing:
// hooks/useDebounce.ts
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cleanup function cancels the timeout if value changes
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Usage in a component
function SearchComponent() {
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearchTerm = useDebounce(searchTerm, 500);
useEffect(() => {
if (debouncedSearchTerm) {
// This only runs 500ms after user stops typing
performSearch(debouncedSearchTerm);
}
}, [debouncedSearchTerm]);
return (
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
);
}
The useLocalStorage Hook
Synchronizing component state with localStorage:
// hooks/useLocalStorage.ts
import { useState, useEffect } from "react";
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// Get initial value from localStorage or use provided initial value
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = (value: T | ((prev: T) => T)) => {
try {
// Allow value to be a function for same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue];
}
// Usage
function SettingsComponent() {
const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
const [username, setUsername] = useLocalStorage("username", "");
return (
<div>
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
Current theme: {theme}
</button>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
/>
</div>
);
}
The useApi Hook
A comprehensive data fetching hook with loading states and error handling:
// hooks/useApi.ts
import { useState, useEffect, useRef } from "react";
interface UseApiState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export function useApi<T>(
url: string,
options?: RequestInit
): UseApiState<T> & { refetch: () => void } {
const [state, setState] = useState<UseApiState<T>>({
data: null,
loading: true,
error: null,
});
const abortControllerRef = useRef<AbortController>();
const fetchData = async () => {
try {
// Cancel any existing request
abortControllerRef.current?.abort();
// Create new abort controller
abortControllerRef.current = new AbortController();
setState((prev) => ({ ...prev, loading: true, error: null }));
const response = await fetch(url, {
...options,
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: T = await response.json();
setState({ data, loading: false, error: null });
} catch (error) {
if (error.name !== "AbortError") {
setState((prev) => ({
...prev,
loading: false,
error: error as Error,
}));
}
}
};
useEffect(() => {
fetchData();
// Cleanup function
return () => {
abortControllerRef.current?.abort();
};
}, [url, JSON.stringify(options)]);
return {
...state,
refetch: fetchData,
};
}
// Usage
function UserList() {
const { data: users, loading, error, refetch } = useApi<User[]>("/api/users");
if (loading) return <div>Loading users...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<button onClick={refetch}>Refresh</button>
<ul>
{users?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
Performance Optimization Techniques
Important: Only optimize when you have measured performance problems. Use React DevTools Profiler to identify bottlenecks before applying these techniques.
React.memo for Component Memoization
Prevents unnecessary re-renders when props haven’t changed:
interface UserCardProps {
user: User;
onEdit: (user: User) => void;
}
// Without memo: re-renders every time parent renders
const UserCard = ({ user, onEdit }: UserCardProps) => {
return (
<div className="user-card">
<h3>{user.name}</h3>
<button onClick={() => onEdit(user)}>Edit</button>
</div>
);
};
// With memo: only re-renders when user or onEdit changes
const MemoizedUserCard = React.memo(UserCard);
// Custom comparison for complex props
const UserCardWithCustomMemo = React.memo(UserCard, (prevProps, nextProps) => {
return (
prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name &&
prevProps.onEdit === nextProps.onEdit
);
});
useCallback for Function Memoization
Prevents creating new function instances on every render:
function UserList({ users }: { users: User[] }) {
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
// Without useCallback: new function created on every render
const handleUserSelect = (userId: number) => {
setSelectedUsers((prev) =>
prev.includes(userId)
? prev.filter((id) => id !== userId)
: [...prev, userId]
);
};
// With useCallback: same function instance between renders
const handleUserSelectMemoized = useCallback((userId: number) => {
setSelectedUsers((prev) =>
prev.includes(userId)
? prev.filter((id) => id !== userId)
: [...prev, userId]
);
}, []); // Empty dependency array because function doesn't depend on any values
return (
<div>
{users.map((user) => (
<MemoizedUserCard
key={user.id}
user={user}
onEdit={handleUserSelectMemoized} // Same function reference
/>
))}
</div>
);
}
useMemo for Expensive Calculations
Memoizes the result of expensive computations:
function DataAnalysis({ data }: { data: DataPoint[] }) {
const [filter, setFilter] = useState("");
// Expensive calculation that should only run when dependencies change
const processedData = useMemo(() => {
console.log("Processing data..."); // This should only log when data or filter changes
return data
.filter((point) => point.label.includes(filter))
.sort((a, b) => b.value - a.value)
.slice(0, 100); // Top 100 items
}, [data, filter]); // Only recalculate when data or filter changes
const statistics = useMemo(() => {
return {
average:
processedData.reduce((sum, point) => sum + point.value, 0) /
processedData.length,
max: Math.max(...processedData.map((point) => point.value)),
min: Math.min(...processedData.map((point) => point.value)),
};
}, [processedData]);
return (
<div>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter data..."
/>
<div>
<p>Average: {statistics.average.toFixed(2)}</p>
<p>Max: {statistics.max}</p>
<p>Min: {statistics.min}</p>
</div>
<ul>
{processedData.map((point) => (
<li key={point.id}>
{point.label}: {point.value}
</li>
))}
</ul>
</div>
);
}
Code Splitting with Lazy Loading
Split your application bundle into smaller chunks that load on demand:
import React, { Suspense, lazy } from "react";
import { Routes, Route } from "react-router-dom";
import LoadingSpinner from "./components/LoadingSpinner";
// Eagerly loaded components for immediate routes
import HomePage from "./pages/HomePage";
import Navigation from "./components/Navigation";
// Lazy loaded components for routes that aren't immediately needed
const Dashboard = lazy(() => import("./pages/Dashboard"));
const UserProfile = lazy(() => import("./pages/UserProfile"));
const AdminPanel = lazy(() => import("./pages/AdminPanel"));
// You can also add loading delays to prevent flash of loading state
const Settings = lazy(() =>
Promise.all([
import("./pages/Settings"),
new Promise((resolve) => setTimeout(resolve, 300)), // Minimum 300ms loading
]).then(([moduleExports]) => moduleExports)
);
function App() {
return (
<div className="app">
<Navigation />
<main className="main-content">
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<UserProfile />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<AdminPanel />} />
</Routes>
</Suspense>
</main>
</div>
);
}
This approach dramatically reduces initial bundle size. Users only download code for the routes they visit.
Portals for Flexible Rendering
Portals allow you to render components outside their normal DOM hierarchy, useful for modals, tooltips, and overlays:
// components/Modal.tsx
import { ReactNode, useEffect } from "react";
import ReactDOM from "react-dom";
interface ModalProps {
children: ReactNode;
isOpen: boolean;
onClose: () => void;
}
function Modal({ children, isOpen, onClose }: ModalProps) {
useEffect(() => {
if (isOpen) {
// Prevent body scrolling when modal is open
document.body.style.overflow = "hidden";
// Close modal on escape key
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => {
document.body.style.overflow = "unset";
document.removeEventListener("keydown", handleEscape);
};
}
}, [isOpen, onClose]);
if (!isOpen) return null;
// Create portal to render modal outside component hierarchy
return ReactDOM.createPortal(
<div
className="modal-overlay"
onClick={(e) => {
// Close when clicking overlay
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div className="modal-content">
<button
className="modal-close"
onClick={onClose}
aria-label="Close modal"
>
×
</button>
{children}
</div>
</div>,
document.body // Render directly into body
);
}
// Usage
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div className="app">
<header>My Application</header>
<main>
<button onClick={() => setIsModalOpen(true)}>Open Modal</button>
{/* Modal renders outside this component tree but behaves normally */}
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
<h2>Modal Content</h2>
<p>This modal is rendered outside the normal component hierarchy.</p>
</Modal>
</main>
</div>
);
}
Add the corresponding CSS:
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
padding: 2rem;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
position: relative;
}
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
}
Architectural Best Practices
Component Organization
Structure your components for maintainability:
src/
├── components/ # Reusable UI components
│ ├── common/ # Basic components (Button, Input, Modal)
│ ├── forms/ # Form-related components
│ └── layout/ # Layout components (Header, Sidebar)
├── pages/ # Page-level components
├── hooks/ # Custom hooks
├── contexts/ # React contexts
├── services/ # API and external service interfaces
├── utils/ # Pure utility functions
├── types/ # TypeScript type definitions
└── constants/ # Application constants
Error Handling Strategy
Implement comprehensive error handling:
// services/api.ts
class ApiError extends Error {
constructor(message: string, public status: number, public endpoint: string) {
super(message);
this.name = "ApiError";
}
}
export class ApiService {
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: {
"Content-Type": "application/json",
...options.headers,
},
...options,
});
if (!response.ok) {
throw new ApiError(
`Request failed: ${response.statusText}`,
response.status,
endpoint
);
}
return await response.json();
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw new ApiError("Network error", 0, endpoint);
}
}
async getUsers(): Promise<User[]> {
return this.request<User[]>("/users");
}
}
Production Readiness Checklist
Before deploying React applications to production:
- Error Boundaries: Implemented around logical sections
- Performance: Measured and optimized bottlenecks
- Code Splitting: Lazy loading for non-critical routes
- Error Logging: Integrated with monitoring services
- Accessibility: ARIA labels, keyboard navigation, screen reader support
- Loading States: Proper feedback during async operations
- Error States: Graceful handling of all failure scenarios
- TypeScript: Full type coverage for maintainability
Conclusion
These advanced patterns and techniques transform React applications from functional demos into robust, scalable production systems. The key is applying them judiciously—not every component needs memoization, not every route needs lazy loading, and not every state needs complex optimization.
Understanding these patterns gives you the toolkit to handle edge cases, performance requirements, and architectural challenges that arise in real-world applications. You can now build React applications that not only work but scale, perform well, and provide excellent user experiences even under demanding conditions.
The journey from learning React syntax to architecting production applications is complete. You now have both the fundamental understanding and the advanced techniques needed to build professional-grade React applications that serve real users at scale.