Advanced patterns for building maintainable and performant React applications
React has evolved significantly since its introduction, and with it, the patterns and practices for building scalable applications have matured. Modern React development leverages hooks, context, and advanced patterns to create maintainable, performant applications that scale with your team and user base.
Custom Hooks
Components
Memoization
Error Boundaries
Custom hooks are the foundation of modern React patterns. They allow you to extract component logic into reusable functions, making your codebase more maintainable and testable.
// useApi.ts - Reusable API hook
import { useState, useEffect, useCallback } from 'react';
interface ApiState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export function useApi<T>(url: string, options?: RequestInit) {
const [state, setState] = useState<ApiState<T>>({
data: null,
loading: true,
error: null
});
const fetchData = useCallback(async () => {
try {
setState(prev => ({ ...prev, loading: true, error: null }));
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setState({ data, loading: false, error: null });
} catch (error) {
setState({
data: null,
loading: false,
error: error instanceof Error ? error : new Error('Unknown error')
});
}
}, [url, options]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { ...state, refetch: fetchData };
}
// Usage in component
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error, refetch } = useApi<User>(`/api/users/${userId}`);
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
if (!user) return <NotFound />;
return <UserCard user={user} />;
}
// useLocalStorage.ts - Persistent state hook
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
// Get from local storage then parse stored json or return initialValue
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;
}
});
// Return a wrapped version of useState's setter function that persists the new value to localStorage
const setValue = (value: T | ((val: T) => T)) => {
try {
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] as const;
}
// Usage
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
Context is powerful for sharing state across your component tree, but it needs to be used wisely to avoid performance issues and maintain clean architecture.
Split your context into separate contexts for state and actions to optimize re-renders.
// Split context pattern for better performance
interface UserState {
user: User | null;
loading: boolean;
error: string | null;
}
interface UserActions {
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
updateProfile: (data: Partial<User>) => Promise<void>;
}
// Separate contexts
const UserStateContext = createContext<UserState | undefined>(undefined);
const UserActionsContext = createContext<UserActions | undefined>(undefined);
// Provider component
export function UserProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<UserState>({
user: null,
loading: false,
error: null
});
const actions: UserActions = useMemo(() => ({
login: async (credentials) => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const user = await authService.login(credentials);
setState({ user, loading: false, error: null });
} catch (error) {
setState(prev => ({
...prev,
loading: false,
error: error.message
}));
}
},
logout: () => {
authService.logout();
setState({ user: null, loading: false, error: null });
},
updateProfile: async (data) => {
// Implementation
}
}), []);
return (
<UserStateContext.Provider value={state}>
<UserActionsContext.Provider value={actions}>
{children}
</UserActionsContext.Provider>
</UserStateContext.Provider>
);
}
// Custom hooks for consuming context
export function useUserState() {
const context = useContext(UserStateContext);
if (!context) {
throw new Error('useUserState must be used within UserProvider');
}
return context;
}
export function useUserActions() {
const context = useContext(UserActionsContext);
if (!context) {
throw new Error('useUserActions must be used within UserProvider');
}
return context;
}
Performance optimization in React involves preventing unnecessary re-renders, optimizing expensive computations, and managing component lifecycle efficiently.
Prevent re-renders when props haven't changed
const ExpensiveComponent = React.memo(
({ data, onUpdate }) => {
return <ComplexVisualization data={data} />;
},
(prevProps, nextProps) => {
// Custom comparison
return prevProps.data.id === nextProps.data.id;
}
);
Memoize expensive calculations and functions
const processedData = useMemo(() => {
return expensiveDataProcessing(rawData);
}, [rawData]);
const handleClick = useCallback((id: string) => {
onItemClick(id);
}, [onItemClick]);
// useVirtualScrolling.ts - Custom hook for large lists
import { useState, useEffect, useMemo } from 'react';
interface VirtualScrollOptions {
itemHeight: number;
containerHeight: number;
overscan?: number;
}
export function useVirtualScrolling<T>(
items: T[],
options: VirtualScrollOptions
) {
const [scrollTop, setScrollTop] = useState(0);
const { itemHeight, containerHeight, overscan = 5 } = options;
const visibleRange = useMemo(() => {
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const endIndex = Math.min(
items.length - 1,
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
);
return { startIndex, endIndex };
}, [scrollTop, itemHeight, containerHeight, overscan, items.length]);
const visibleItems = useMemo(() => {
return items.slice(visibleRange.startIndex, visibleRange.endIndex + 1)
.map((item, index) => ({
item,
index: visibleRange.startIndex + index
}));
}, [items, visibleRange]);
const totalHeight = items.length * itemHeight;
const offsetY = visibleRange.startIndex * itemHeight;
return {
visibleItems,
totalHeight,
offsetY,
onScroll: (e: React.UIEvent<HTMLElement>) => {
setScrollTop(e.currentTarget.scrollTop);
}
};
}
// Usage in component
function LargeList({ items }: { items: Item[] }) {
const { visibleItems, totalHeight, offsetY, onScroll } = useVirtualScrolling(
items,
{ itemHeight: 50, containerHeight: 400 }
);
return (
<div className="h-96 overflow-auto" onScroll={onScroll}>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleItems.map(({ item, index }) => (
<div key={index} className="h-12 border-b">
<ItemComponent item={item} />
</div>
))}
</div>
</div>
</div>
);
}
Robust error handling is crucial for production applications. React Error Boundaries provide a way to catch JavaScript errors anywhere in the component tree.
// ErrorBoundary.tsx - Modern error boundary implementation
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;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
this.props.onError?.(error, errorInfo);
// Log to error reporting service
if (process.env.NODE_ENV === 'production') {
// errorReportingService.logError(error, errorInfo);
}
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="p-6 bg-red-50 border border-red-200 rounded-lg">
<h2 className="text-lg font-semibold text-red-800 mb-2">
Something went wrong
</h2>
<p className="text-red-600 mb-4">
We're sorry, but something unexpected happened.
</p>
<button
onClick={() => this.setState({ hasError: false })}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
// Hook for error boundary in functional components
export function useErrorHandler() {
const [error, setError] = useState<Error | null>(null);
const resetError = () => setError(null);
const captureError = (error: Error) => {
setError(error);
};
if (error) {
throw error; // This will be caught by the nearest Error Boundary
}
return { captureError, resetError };
}
Testing React components with hooks and context requires specific patterns and tools. Here's how to test modern React applications effectively.
// useApi.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useApi } from './useApi';
// Mock fetch
global.fetch = jest.fn();
describe('useApi', () => {
beforeEach(() => {
(fetch as jest.Mock).mockClear();
});
it('should fetch data successfully', async () => {
const mockData = { id: 1, name: 'Test User' };
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockData
});
const { result } = renderHook(() => useApi('/api/users/1'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
});
it('should handle errors properly', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useApi('/api/users/1'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBeTruthy();
expect(result.current.data).toBe(null);
});
});
Modern React development is about more than just writing components. It's about creating maintainable, performant, and accessible applications that scale with your team and user base.
By implementing these patterns and best practices, you'll build React applications that are not only functional but also robust, testable, and ready for production. Remember that these patterns should be applied judiciously – not every component needs to be memoized, and not every piece of state needs to be in context.
Start implementing these patterns in your next React project.
Share it with your development team and help spread modern React knowledge.