In the quest for maintainable and scalable React applications, developers often turn too quickly to external state management libraries (Redux, Zustand, etc.). However, for the majority of application state, the most elegant, testable, and idiomatic solutions lie directly within the React ecosystem itself.
This article explores how to rely on colocated state and advanced component composition patterns—using only core React hooks—to solve complexity and entirely bypass the need for prop drilling or external state libraries.
Colocated State: The Default Principle
Colocation is the practice of placing related code (state, logic, and UI) as close as possible to where it is consumed. This should always be your starting point for state management.
The Hierarchy of State Simplicity
Always ask: "What is the highest component in the tree that needs this state?"
-
Local Component State (
useState
/useReducer
): If the state is only needed by a single component (e.g., the open/closed/toggle status of a modal, the value of a local input field, form validation logic), the state should live in that component.- Benefit: Maximum isolation, easy to test, no external dependencies.
- Example: A
FilterPanel
component manages its own[isCollapsed, setIsCollapsed]
state.
-
Lifted State (Prop Passing): If the state is needed by a component and its immediate, shallow children, lift the state to the closest common parent and pass it down as props. Prop drilling is not a problem when the component hierarchy is shallow (2-3 levels) and the intermediary components actually use the data.
What is prop drilling?
Answer: Prop drilling is passing props through components that don’t use them, just to reach a deeply nested child.
Eliminating Prop Drilling with Component Composition
Prop drilling becomes a problem when you pass a prop through five or more components that do not use the data themselves, simply acting as forwarders. The solution is not always a global store; it is often better component architecture, achieved through composition.
The goal is to move the responsibility of data access and placement up to the closest intelligent parent, leaving the intermediate components simple and reusable.
1. The children
Prop (The Fundamental Fix)
Instead of passing the data through a component, pass the component that needs the data as a children
prop. This allows the parent component (the one holding the state) to render the final consuming component directly.
The Problem (Prop Drilling):
// App.jsx (holds state)
function App() {
const [user, setUser] = useState({ name: "Alice" });
return <Layout user={user} />; // Layout doesn't need 'user'
}
// Layout.jsx (unnecessary prop forwarding)
function Layout({ user }) {
// receives 'user' but only passes it on
return <Header user={user} />;
}
The Solution (Composition with children
):
// App.jsx (The smart parent)
function App() {
const [user, setUser] = useState({ name: "Alice" });
// App renders the data-consuming component (Header) directly as a child.
return (
<Layout>
<Header userName={user.name} />
</Layout>
);
}
// Layout.jsx (A reusable, data-agnostic container)
function Layout({ children }) {
// It only receives and renders its children, it doesn't know about 'user'.
return <div className="layout">{children}</div>;
}
// Header.jsx (Only receives the data it needs)
function Header({ userName }) {
return <h1>Welcome, {userName}</h1>;
}
The Layout
component is now completely decoupled from the user logic, eliminating the prop drilling entirely.
2. Logic Colocation with Custom Hooks
When component logic becomes complex, extract it into a custom hook. This isn't a state management solution, but a powerful colocation technique.
Before (Monolithic Component):
function ShoppingCart() {
const [items, setItems] = useState([]);
// 50 lines of logic to calculate totals, handle quantity, etc.
// ...
}
After (Colocated Logic with Custom Hook):
// useShoppingCart.js (Colocated logic)
export function useShoppingCart() {
const [items, setItems] = useState([]);
// ... all the logic for calculating totals, handling quantity
return { items, addItem, removeItem, total: calculateTotal(items) };
}
// ShoppingCart.jsx (Clean UI layer)
function ShoppingCart() {
const { items, addItem, removeItem, total } = useShoppingCart();
return (
// ... clean rendering of the cart
);
}
The state (items
) and its complex logic are colocated in the useShoppingCart
hook, making the primary component a clean, testable view layer.
Conclusion: Rely on React's Core Strengths
By embracing the principles of local state and mastering component composition, you can build powerful, flexible, and scalable React applications without introducing the complexity and overhead of external state management libraries.
- State Colocation: Keep state at the lowest possible level (
useState
/useReducer
). - Composition Over Drilling: Use the
children
prop and patterns like Compound Components to skip intermediary components that don't need the data. - Logic Extraction: Use Custom Hooks to colocate complex logic with its state, creating clean and reusable interfaces.
By following this approach, you let React do what it does best: manage the component tree and its local state efficiently, resulting in a cleaner codebase and a happier development team.