React has evolved significantly since its initial release, and with it, the patterns and best practices for building robust applications have also evolved. In this post, I'll share some of the most important React patterns that every developer should understand and use.
1. Compound Components Pattern
The compound components pattern allows you to create flexible and reusable components by composing them together. This pattern is particularly useful for complex UI components like modals, accordions, or form controls.
// Modal compound component const Modal = ({ children, isOpen, onClose }) => { if (!isOpen) return null; return ( <div className="modal-overlay" onClick={onClose}> <div className="modal-content" onClick={(e) => e.stopPropagation()}> {children} </div> </div> ); }; Modal.Header = ({ children }) => ( <div className="modal-header">{children}</div> ); Modal.Body = ({ children }) => ( <div className="modal-body">{children}</div> ); Modal.Footer = ({ children }) => ( <div className="modal-footer">{children}</div> ); // Usage function App() { const [isOpen, setIsOpen] = useState(false); return ( <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}> <Modal.Header> <h2>Confirm Action</h2> </Modal.Header> <Modal.Body> <p>Are you sure you want to proceed?</p> </Modal.Body> <Modal.Footer> <button onClick={() => setIsOpen(false)}>Cancel</button> <button onClick={handleConfirm}>Confirm</button> </Modal.Footer> </Modal> ); }
2. Custom Hooks for Logic Reuse
Custom hooks are one of the most powerful features in React for sharing stateful logic between components.
// Custom hook for API calls function useApi(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchData = async () => { try { setLoading(true); const response = await fetch(url); if (!response.ok) throw new Error('Failed to fetch'); const result = await response.json(); setData(result); } catch (err) { setError(err.message); } finally { setLoading(false); } }; fetchData(); }, [url]); return { data, loading, error }; } // Custom hook for local storage function useLocalStorage(key, initialValue) { const [storedValue, setStoredValue] = useState(() => { try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch (error) { console.error(error); return initialValue; } }); const setValue = (value) => { try { setStoredValue(value); window.localStorage.setItem(key, JSON.stringify(value)); } catch (error) { console.error(error); } }; return [storedValue, setValue]; }
3. Context + Reducer Pattern
For more complex state management, combining Context API with useReducer provides a powerful alternative to external state management libraries.
// Actions const ACTIONS = { ADD_TODO: 'ADD_TODO', TOGGLE_TODO: 'TOGGLE_TODO', DELETE_TODO: 'DELETE_TODO', }; // Reducer function todoReducer(state, action) { switch (action.type) { case ACTIONS.ADD_TODO: return { ...state, todos: [...state.todos, { id: Date.now(), text: action.payload, completed: false }] }; case ACTIONS.TOGGLE_TODO: return { ...state, todos: state.todos.map(todo => todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo ) }; case ACTIONS.DELETE_TODO: return { ...state, todos: state.todos.filter(todo => todo.id !== action.payload) }; default: return state; } } // Context const TodoContext = createContext(); export function TodoProvider({ children }) { const [state, dispatch] = useReducer(todoReducer, { todos: [] }); const actions = { addTodo: (text) => dispatch({ type: ACTIONS.ADD_TODO, payload: text }), toggleTodo: (id) => dispatch({ type: ACTIONS.TOGGLE_TODO, payload: id }), deleteTodo: (id) => dispatch({ type: ACTIONS.DELETE_TODO, payload: id }), }; return ( <TodoContext.Provider value={{ state, actions }}> {children} </TodoContext.Provider> ); } export function useTodos() { const context = useContext(TodoContext); if (!context) { throw new Error('useTodos must be used within TodoProvider'); } return context; }
4. Render Props Pattern
Although less common with hooks, render props are still useful for sharing code between components.
function DataFetcher({ url, render }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { fetch(url) .then(response => response.json()) .then(data => { setData(data); setLoading(false); }) .catch(error => { setError(error); setLoading(false); }); }, [url]); return render({ data, loading, error }); } // Usage function UserList() { return ( <DataFetcher url="/api/users" render={({ data, loading, error }) => { if (loading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return ( <ul> {data.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> ); }} /> ); }
5. Higher-Order Components (HOCs)
HOCs are functions that take a component and return a new component with additional props or behavior.
function withAuth(WrappedComponent) { return function AuthenticatedComponent(props) { const [isAuthenticated, setIsAuthenticated] = useState(false); const [loading, setLoading] = useState(true); useEffect(() => { // Check authentication status checkAuthStatus() .then(setIsAuthenticated) .finally(() => setLoading(false)); }, []); if (loading) { return <div>Loading...</div>; } if (!isAuthenticated) { return <div>Please log in to continue.</div>; } return <WrappedComponent {...props} />; }; } // Usage const ProtectedDashboard = withAuth(Dashboard);
6. Error Boundaries
Error boundaries catch JavaScript errors anywhere in the child component tree and display a fallback UI.
class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error) { return { hasError: true, error }; } componentDidCatch(error, errorInfo) { console.error('Error caught by boundary:', error, errorInfo); } render() { if (this.state.hasError) { return this.props.fallback || ( <div> <h2>Something went wrong.</h2> <p>{this.state.error?.message}</p> </div> ); } return this.props.children; } } // Usage function App() { return ( <ErrorBoundary fallback={<ErrorFallback />}> <Header /> <MainContent /> <Footer /> </ErrorBoundary> ); }
Performance Optimization Patterns
React.memo for Component Memoization
const ExpensiveComponent = React.memo(({ data, onUpdate }) => { // Expensive rendering logic return <div>{/* Rendered content */}</div>; }, (prevProps, nextProps) => { // Custom comparison function return prevProps.data.id === nextProps.data.id; });
useMemo and useCallback
function OptimizedComponent({ items, filter }) { // Memoize expensive calculations const filteredItems = useMemo(() => { return items.filter(item => item.category === filter); }, [items, filter]); // Memoize callback functions const handleItemClick = useCallback((id) => { // Handle click logic }, []); return ( <div> {filteredItems.map(item => ( <Item key={item.id} item={item} onClick={handleItemClick} /> ))} </div> ); }
Best Practices Summary
- Keep components small and focused: Each component should have a single responsibility
- Use custom hooks for reusable logic: Extract stateful logic into custom hooks
- Leverage composition over inheritance: Use composition patterns for flexibility
- Optimize performance judiciously: Don't optimize prematurely, but know the tools
- Handle errors gracefully: Use error boundaries and proper error handling
- Write testable code: Structure your components to be easily testable
Conclusion
These patterns represent the evolution of React development practices. By mastering them, you'll be able to build more maintainable, performant, and scalable React applications.
Remember, patterns are tools in your toolbox - use them when they solve real problems, not just because they exist. The key is understanding when and why to apply each pattern.
Happy coding!