Ever spent hours untangling a messy React component tree, only to realize that every change breaks something else? Or maybe you’ve struggled to onboard a new teammate, and they get lost in a sea of prop drilling and context spaghetti. Building scalable frontends isn’t just about writing more code—it's about using the right patterns so your app can grow without collapsing under its own weight.
Mastering these seven React patterns can help you write apps that are easier to understand, extend, and maintain—even as your team and codebase grow.
1. Compound Components
Compound components allow you to build flexible, declarative APIs by composing several related components together. This pattern is perfect when you want to create rich, interactive UI elements (think dropdowns, tabs, or modals) where parent and child components need to communicate.
Why use it?
It gives users of your components (including yourself!) a lot of control over structure and content, while centralizing logic in a single place.
Example: Custom Tabs
// Tabs.js
import React, { createContext, useContext, useState } from "react";
// Context to share active tab state
const TabsContext = createContext();
export function Tabs({ children, defaultIndex = 0 }) {
const [activeIndex, setActiveIndex] = useState(defaultIndex);
return (
<TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
<div>{children}</div>
</TabsContext.Provider>
);
}
export function TabList({ children }) {
return <div style={{ display: "flex" }}>{children}</div>;
}
export function Tab({ index, children }) {
const { activeIndex, setActiveIndex } = useContext(TabsContext);
const isActive = activeIndex === index;
return (
<button
style={{
borderBottom: isActive ? "2px solid blue" : "none",
fontWeight: isActive ? "bold" : "normal"
}}
onClick={() => setActiveIndex(index)}
>
{children}
</button>
);
}
export function TabPanels({ children }) {
const { activeIndex } = useContext(TabsContext);
// Only render the active panel
return <div>{children[activeIndex]}</div>;
}
export function TabPanel({ children }) {
return <div>{children}</div>;
}
Usage:
<Tabs defaultIndex={0}>
<TabList>
<Tab index={0}>First</Tab>
<Tab index={1}>Second</Tab>
</TabList>
<TabPanels>
<TabPanel>First panel content</TabPanel>
<TabPanel>Second panel content</TabPanel>
</TabPanels>
</Tabs>
Key idea:
The parent holds the logic. Children just render UI and communicate via context—no prop drilling required.
2. Render Props
Render props are functions provided as children or props that let you control rendering. This allows for sharing logic between components without inheritance or higher-order components (HOCs).
When is it useful?
When you want to share non-UI logic (like UI state or event handling) with complete UI flexibility.
Example: Mouse Tracker
function MouseTracker({ children }) {
const [position, setPosition] = React.useState({ x: 0, y: 0 });
React.useEffect(() => {
const handleMouseMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, []);
// children is a function: (state) => ReactNode
return children(position);
}
// Usage:
<MouseTracker>
{({ x, y }) => (
<div>
The mouse position is ({x}, {y})
</div>
)}
</MouseTracker>
Trade-off:
Render props can lead to deeply nested code ("wrapper hell") if overused, but for certain shared logic, they're still a clean solution.
3. Controlled vs. Uncontrolled Components
Controlled components have their state managed by React. Uncontrolled components let the DOM manage state (using refs). Both have their place—forms are the classic example.
Rule of thumb:
Use controlled components for complex forms or when you need to react to every change. Use uncontrolled for quick-and-dirty forms or when integrating with non-React code.
Example: Controlled Input
function NameForm() {
const [name, setName] = React.useState("");
function handleChange(e) {
setName(e.target.value); // React updates state on every change
}
function handleSubmit(e) {
e.preventDefault();
alert(`Submitted name: ${name}`);
}
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={handleChange} /> {/* Controlled */}
<button type="submit">Submit</button>
</form>
);
}
Uncontrolled version would use ref instead of state.
4. Provider Pattern (Context API)
Context is React’s built-in way to share data “globally” (like theme, auth, or user settings) without prop drilling. The provider pattern wraps parts of your app in a context provider and exposes hooks or consumers for easy access.
Best practice:
Keep contexts focused—don’t throw your whole app state into context. Use it for truly shared state.
Tip:
Wrap your context value in useMemo if it contains objects/functions to avoid unnecessary renders.
5. Custom Hooks
Custom hooks are reusable logic extracted into functions starting with use. They’re the best way to encapsulate and share logic in modern React.
Why they're powerful:
Hooks let you share stateful logic (not just state) across components, without changing your component tree.
Example: useLocalStorage
import React from "react";
// Save and read a value from localStorage
function useLocalStorage(key, initialValue) {
const [value, setValue] = React.useState(() => {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
});
React.useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
// Usage:
function Counter() {
const [count, setCount] = useLocalStorage("count", 0);
return (
<button onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
);
}
6. State Colocation
State colocation means keeping state as close as possible to where it’s used, rather than lifting everything up to a top-level parent. This makes components easier to understand and maintain.
How to spot a problem:
If you have a parent component that's just a container for state and passes it down to lots of children, consider colocating the state.
Trade-off:
Don’t duplicate state if multiple children need to coordinate—but avoid global state unless it’s truly global.
7. Suspense for Data Fetching
React's Suspense API allows you to declaratively handle loading states for async operations—like data fetching or code splitting. As of mid-2024, major React data libraries (like React Query and Relay) support Suspense, making loading states easier and cleaner.
How it works:
You wrap your component in a <Suspense> boundary and let React handle the loading state when your data is not ready.
Example: Suspense with Data Fetching
import React, { Suspense } from "react";
// Simulated fetch function for demo purposes
function fetchUser() {
let status = "pending";
let result;
const suspender = fetch("https://jsonplaceholder.typicode.com/users/1")
.then((r) => r.json())
.then(
(data) => {
status = "success";
result = data;
},
(error) => {
status = "error";
result = error;
}
);
return {
read() {
if (status === "pending") throw suspender;
if (status === "error") throw result;
return result;
}
};
}
const userResource = fetchUser();
function UserProfile() {
const user = userResource.read(); // May throw promise if loading
return (
<div>
<h3>{user.name}</h3>
<div>Email: {user.email}</div>
</div>
);
}
export default function App() {
return (
<Suspense fallback={<div>Loading user...</div>}>
<UserProfile />
</Suspense>
);
}
Note:
This is a simplified example. In real apps, use libraries like React Query or Relay for robust data management and Suspense support.
Common Mistakes
Overusing Context for All State
Throwing your entire app state into context leads to unnecessary re-renders and performance issues. Only use context for truly shared, global data.Not Collocating State
Lifting state up too far creates "prop drilling" and makes components harder to reuse. Keep state close to where it changes, unless multiple components need to coordinate.Ignoring Key Abstractions
Skipping custom hooks or compound components in favor of duplicating logic leads to bloated, harder-to-maintain code. Invest time in reusable patterns—they pay off fast.
Key Takeaways
- Compound components enable flexible, declarative APIs for complex UI elements.
- Render props and custom hooks are great for sharing non-UI logic and stateful behavior across your app.
- Use controlled components for complex forms, but don’t overlook uncontrolled components for simple use-cases.
- State colocation and minimal context usage make your code easier to follow and scale.
- Suspense, when combined with modern data-fetching libraries, simplifies loading and error handling for async data.
Every scalable React app I’ve seen uses some combination of these patterns. Try them out in your next project—you’ll spend less time untangling code, and more time building features that matter.
If you found this helpful, check out more programming tutorials on our blog. We cover Python, JavaScript, Java, Data Science, and more.
Top comments (0)