When working with React at scale, writing clean, reusable, and maintainable components becomes critical. That’s where design patterns come into play. In this article, we’ll explore some of the most effective React design patterns used in real-world applications: the Container Component Pattern, Controlled vs Uncontrolled Components, Compound Components, Higher-Order Components (HOCs), the Hooks Pattern, and the Context API Pattern.
Container & Presentational Component Pattern
In React, the Container and Presentational Component Pattern (also known as the Smart and Dumb Components pattern) is a way to enforce Separation of Concerns. This pattern splits components into two distinct roles:
- Container Components: that handle data fetching, business logic, and state management.
- Presentational Components: that focus purely on rendering the UI based upon data received via props.
Separation of Concerns
Separation of Concerns (SoC) is a fundamental design principle in computer programming that involves dividing a program into distinct sections, each responsible for a specific functionality (or concern). A "concern" refers to a particular aspect of the system's behaviour -- such as data management, UI rendering, business logic, or networking. By separating concerns, you create modular, readable, and maintainable code. Each module or component can be developed, tested, and updated independently, without affecting unrelated parts of the system.
For example, a UserContainer component might fetch user data using useEffect and store it in state, then pass that data down to a UserProfile component. The UserProfile component receives the user data as a prop and takes care of how it’s displayed, without knowing where the data came from or how it was loaded.
1// UserContainer.jsx 2 3import UserProfile from "./UserProfile.jsx"; 4 5function UserContainer() { 6 const [user, setUser] = useState(null); 7 8 useEffect(() => { 9 fetchUser().then(setUser); 10 }, []); 11 12 return <UserProfile user={user} />; 13} 14 15export default UserContainer;
1// UserProfile.jsx 2 3function UserProfile({ user }) { 4 return user ? <div>{user.name}</div> : <p>Loading...</p>; 5} 6 7export default UserProfile;
Compound Component Pattern
The Compound Component Pattern is a design pattern where multiple components work together as a cohesive unit. A parent component holds shared logic or state, while child components (typically namespaced under the parent) are used to structure and render content.
1// Card.jsx 2 3function Card({ children }) { 4 return ( 5 <div style={{ padding: "16px", border: "1px solid #e5e7eb", borderRadius: "4px" }}> 6 {children} 7 </div> 8 ); 9} 10 11Card.Title = function CardTitle({ children }) { 12 return ( 13 <h4 14 style={{ 15 fontSize: "20px", 16 margin: 0 17 fontWeight: 500, 18 }} 19 > 20 {children} 21 </h4> 22 ); 23}; 24 25Card.Content = function CardContent({ children }) { 26 return <p style={{ marginTop: "8px", marginBottom: 0 }}>{children}</p>; 27}; 28 29export default Card;
1// App.jsx 2 3import Card from "./Card.jsx" 4 5export default function App() { 6 return ( 7 <main> 8 <Card> 9 <Card.Title>This is a cool title</Card.Title> 10 <Card.Content> 11 Lorem ipsum dolor sit amet consectetur adipisicing elit. Exercitationem, 12 delectus quam vitae et nostrum aspernatur maxime quod cupiditate 13 repudiandae ex ducimus reiciendis, quos fugit. 14 </Card.Content> 15 </Card> 16 </main> 17 ) 18}
The Compound Component Pattern facilitates component composition which refers to the practice of building complex UIs by combining smaller, reusable components together like building blocks. This allows developers to build components that behave like a natural hierarchy -- where multiple related subcomponents are composed inside a single parent component.
Compound Component Pattern is most commonly seen in UI libraries and design systems -- Mantine and Radix UI
Controlled and Uncontrolled Component Pattern
In React, a component that deals with form elements like input, select, textarea, and checkbox can be categorized as either a controlled or uncontrolled component. The distinction refers to how they manage and update their state.
A component is considered a controlled component when you let React manage its state. This means the component that wraps the form elements handles their state internally and updates it as needed. By linking the value of the form elements to the component's state, the component state becomes the "single source of truth".
This is the recommended way to write components because React takes control of when and how they should render, eliminating the need to manually work with low-level DOM APIs.
1// ControlledInput.jsx 2 3import { Fragment, useState } from "react"; 4 5function ControlledInput() { 6 const [value, setValue] = useState(""); 7 8 const handleChange = (event) => { 9 const value = event.target.value; 10 setValue(value); 11 }; 12 13 return ( 14 <Fragment> 15 <input 16 style={{ 17 outline: "none", 18 height: "40px", 19 paddingLeft: "16px", 20 paddingRight: "16px", 21 border: "1px solid #D1D5DB", 22 borderRadius: "6px", 23 }} 24 value={value} 25 onChange={handleChange} 26 /> 27 <p className="text-sm mt-1">Value: {value}</p> 28 </Fragment> 29 ); 30} 31 32export default ControlledInput;
An uncontrolled component is one that does not manage its own state but relies on the DOM to handle the element’s value. With an uncontrolled component, you work directly with a reference to the DOM, making it feel closer to vanilla JavaScript. This provides a low-level way to interact with form elements without relying on the component's state.
React does not control the input values directly instead you use refs to access the values when needed.
1// UncontrolledInput.jsx 2 3import { useRef } from "react"; 4 5function UncontrolledInput() { 6 const inputRef = useRef(""); 7 8 const handleChange = () => { 9 if (inputRef.current) { 10 const value = inputRef.current.value; 11 12 // NOTE: React does not re-render when ref values change 13 // Logging value 14 console.log("value:", value); 15 } 16 }; 17 18 return ( 19 <Fragment> 20 <input 21 ref={inputRef} 22 style={{ 23 outline: "none", 24 height: "40px", 25 paddingLeft: "16px", 26 paddingRight: "16px", 27 border: "1px solid #D1D5DB", 28 borderRadius: "6px", 29 }} 30 defaultValue="" 31 onChange={handleChange} 32 /> 33 </Fragment> 34 ); 35} 36 37export default UncontrolledInput;
Higher-Order Component (HOC) Pattern
A Higher-Order Component (HOC) is a React pattern used to reuse logic across components. An HOC is a function that takes a React component as an argument and returns a new, enhanced component with added functionality or modified behaviour.
1// higherOrderComponent.jsx 2 3import React from "react"; 4 5// Take in a component as argument WrappedComponent 6const higherOrderComponent = (WrappedComponent) => { 7 8 // Enhance the component by adding functionality 9 const EnhancedComponent = (props) => { 10 return <WrappedComponent {...props} />; 11 } 12 13 // Return the enhanced component 14 return EnhancedComponent; 15} 16 17export default higherOrderComponent;
Some points to consider while creating HOCs:
- We don’t modify or mutate components. We create new ones.
- A HOC is used to compose components for code reuse.
- A HOC is a pure function. It has no side effects, returning only a new component.
Higher-Order Components (HOCs) incorporate the "Don’t Repeat Yourself (DRY)" principle by enabling developers to extract and reuse common logic across multiple components. Instead of duplicating similar code in different places, HOCs allow you to encapsulate shared behavior once and apply it wherever needed. This leads to cleaner, more maintainable code and reduces the chance of bugs caused by inconsistent implementations.
DRY
The Don't Repeat Yourself (DRY) principle is a foundational concept in programming that encourages reducing repetition of code. Instead of writing the same logic or code in multiple places, you should abstract it into a single reusable unit -- like a function, class, or component -- and reference it wherever needed. This makes the code easier to maintain and reduces errors.
Custom Hook Pattern
A custom hook in React is a JavaScript function that encapsulates reusable logic and can call other React hooks like useState and useEffect. A custom hook starts with the name use like -- useFetch, useLocalStorage, useDebounce etc.
Custom hooks help keep your components clean and focused on UI, while complex or repetitive logic lives elsewhere in an easily testable and reusable format. For example, instead of repeating data fetching logic in multiple components you can write a custom hook like useFetch to handle it
1// useFetch.js 2 3import { useEffect, useState } from "react"; 4 5export function useFetch(url) { 6 const [data, setData] = useState(null); 7 const [isLoading, setIsLoading] = useState(true); 8 const [error, setError] = useState(null); 9 10 useEffect(() => { 11 const fetchData = async () => { 12 try { 13 const response = await fetch(url); 14 const json = await response.json(); 15 setData(json); 16 } catch (err) { 17 setError(err); 18 } finally { 19 setLoading(false); 20 } 21 } 22 23 fetchData(); 24 }, [url]]; 25 26 return { data, isLoading, error }; 27} 28 29export default useFetch;
1// Users.jsx 2 3import useFetch from "./useFetch.js"; 4 5function Users() { 6 const { data, isLoading, error } = useFetch("https://jsonplaceholder.typicode.com/users"); 7 8 if (isLoading) return <p>Loading...</p>; 9 if (error) return <p>Error: {error.message}</p> 10 11 const users = data && data.users; 12 13 return ( 14 <ul> 15 {users && users.length > 0 && users.map((user) => { 16 return <li key={user.id}>{user.name}</li>; 17 })} 18 </ul> 19 ) 20} 21 22export default Users;
This useFetch hook encapsulates data fetching logic in a reusable and clean way, allowing the Users component to focus solely on rendering the UI.