Container/Presentational Pattern with Render Props
The "Secret Sauce" for scalable React apps. Learn how to separate logic from UI, build reusable data sources, and create maintainable components that scale with your application.
๐ Separation of Concerns
This pattern tackles the biggest headache in large codebases: logic and UI tangled together like headphone cables! ๐ง By splitting the 'what to fetch' from the 'how to display,' you unlock true scalability and reusability! ๐โจ
๐ด Impact: CRITICAL โ The foundational architecture principle that separates junior code from senior code. Get this right, everything else follows! ๐๏ธ
๐ In this section: Container vs Presentational โข Logic layer โข UI layer โข Why decoupling matters
The Container (Logic)
Handles fetching data, loading states, and errors. It doesn't care what the data looks like on screen.
The Presentational Component (UI)
Just renders props. It doesn't care where the data comes from.
๐ซ The "Junior" Way (Hard to Scale) โ
We've all been here! ๐ Fetch logic trapped inside the component like a bird in a cage. Need the same user data elsewhere? Time to copy-paste... and that's where the nightmare begins! ๐๐ Let's see what NOT to do.
๐ด Impact: CRITICAL โ Recognizing anti-patterns is the first step to writing better code. If your components fetch AND render, read on! ๐จ
๐ In this section: Coupled logic anti-pattern โข Copy-paste problems โข Testing difficulties โข Duplicated loading states
// UserProfile.js - mock: simulate fetch with mock dataconst MOCK_USER = { name: "Alex", email: "alex@example.com" };const UserProfile = () => {const [user, setUser] = useState(null);useEffect(() => {const t = setTimeout(() => setUser(MOCK_USER), 500);return () => clearTimeout(t);}, []);if (!user) return <div>Loading...</div>;return <h1>{user.name}</h1>;};function App() { return <UserProfile />; }export default App;
Problems with this approach:
- Logic is tightly coupled to the UI component
- Can't reuse the data fetching logic elsewhere
- Hard to test - need to mock fetch for UI tests
- Loading states duplicated across components
โ The "Senior" Way: The Generic DataSource
Why write a loader for EVERY data type when you can write ONE that rules them all? ๐ The Generic DataSource is your Swiss Army knife โ one component that handles fetching, loading, and error states for ANY data. It's beautiful! ๐คฉ
This covers both the DataSource and Render Props patterns โ two patterns for the price of one! ๐
๐ Impact: HIGH โ One reusable component to handle ALL your data fetching. Write once, use everywhere โ that's the senior way! ๐
๐ In this section: Generic DataSource component โข Render Props pattern โข Dynamic prop naming โข Reusable loading logic
The Container (Logic Layer)
It takes a "getData" function and passes the result to "children" using Render Props:
import { useState, useEffect } from 'react';const DataSource = ({ getDataFunc, resourceName, children }) => {const [state, setState] = useState(null);useEffect(() => {(async () => {const data = await getDataFunc();setState(data);})();}, [getDataFunc]);if (!state) return <p>Loading...</p>;return children({ [resourceName]: state });};const MOCK_USER = { name: "Jordan", email: "jordan@example.com" };const getMockUser = () => new Promise(r => setTimeout(() => r(MOCK_USER), 400));function UserInfo({ user }) {return <div><h2>{user.name}</h2><p>{user.email}</p></div>;}function App() {return (<DataSource getDataFunc={getMockUser} resourceName="user">{({ user }) => <UserInfo user={user} />}</DataSource>);}export default App;
Key Points:
- Render Props:
childrenis a function that receives data - Dynamic Prop Naming: Uses
[resourceName]to create flexible prop names - Reusable Logic: One component handles all data fetching patterns
- Separation: Logic is completely separate from UI
๐จ The "Dumb" UI Components
Don't let the name fool you โ "dumb" components are actually genius! ๐ง They're pure, predictable, and ridiculously easy to test. Give them props, they render. No side effects, no drama, no surprises. Same input = same output, every single time! ๐ฏ
๐ข Impact: LOW โ Simple concept, massive payoff. Pure components are the building blocks of every maintainable codebase! ๐งฑ
๐ In this section: Pure presentational components โข Zero side effects โข Easy testing โข Reusable UI pieces
const UserInfo = ({ user }) => (<div style={{ border: '1px solid #333', padding: 16 }}><h1 style={{ fontSize: '1.25rem' }}>{user.name}</h1><p>Email: {user.email}</p></div>);const ProductInfo = ({ product }) => (<div style={{ background: '#222', padding: 16 }}><h2>{product.title}</h2><p>Price: ${product.price}</p></div>);const MOCK_USER = { name: "Sam", email: "sam@example.com" };const MOCK_PRODUCT = { title: "React Book", price: 29.99 };function App() {return (<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}><UserInfo user={MOCK_USER} /><ProductInfo product={MOCK_PRODUCT} /></div>);}export default App;
Benefits of Pure Components:
- Easy to Test: Just pass props, no mocking needed
- Easy to Style: No side effects to worry about
- Reusable: Can be used with any data source
- Predictable: Same props = same output, always
๐งฉ Putting It Together (The Scalability Win) ๐
Now for the magic moment! โจ Watch how clean your App becomes when you combine DataSource + Presentational components. Mix and match ANY data source with ANY UI component โ it's like a buffet of reusability! ๐๐
๐ Impact: HIGH โ This is where it all comes together. See the full power of Container/Presentational in action! ๐ฌ
๐ In this section: Combining patterns โข Mix-and-match data sources โข Clean App architecture โข Type-safe composition
import { useState, useEffect } from 'react';const DataSource = ({ getDataFunc, resourceName, children }) => {const [state, setState] = useState(null);useEffect(() => {(async () => { const data = await getDataFunc(); setState(data); })();}, [getDataFunc]);if (!state) return <p>Loading...</p>;return children({ [resourceName]: state });};const UserInfo = ({ user }) => <div><h3>{user.name}</h3><p>{user.email}</p></div>;const ProductInfo = ({ product }) => <div><h3>{product.title}</h3><p>${product.price}</p></div>;const api = {getCurrentUser: () => new Promise(r => setTimeout(() => r({ name: "Demo User", email: "demo@example.com" }), 300)),getProduct: () => new Promise(r => setTimeout(() => r({ title: "React Guide", price: 39.99 }), 300)),};function App() {return (<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}><DataSource getDataFunc={api.getCurrentUser} resourceName="user">{({ user }) => <UserInfo user={user} />}</DataSource><DataSource getDataFunc={() => api.getProduct(123)} resourceName="product">{({ product }) => <ProductInfo product={product} />}</DataSource></div>);}export default App;
The Power of This Pattern:
- One Logic Component: Write loading/error logic once, use everywhere
- Mix & Match: Any data source with any UI component
- Clean App: Your main App component stays readable and maintainable
- Type Safety: Easy to add TypeScript for better DX
๐พ Applying it to LocalStorage
Here's where minds get blown! ๐คฏ The DataSource pattern is so flexible that the source doesn't have to be an API at all. LocalStorage? Sure! IndexedDB? Why not! The UI component doesn't even know the difference โ and that's the whole point! ๐ฉโจ
๐ต Impact: MEDIUM โ Proof that great abstractions work everywhere. Same pattern, different source, zero changes to your UI! ๐
๐ In this section: LocalStorage data source โข Source-agnostic UI โข Swappable data layers โข Abstraction power
const LocalStorageLoader = ({ keyName, children }) => {const [data, setData] = useState(null);useEffect(() => {try {const saved = localStorage.getItem(keyName);setData(saved ? JSON.parse(saved) : null);} catch {setData(null);}}, [keyName]);return children({ data });};function App() {return (<LocalStorageLoader keyName="theme-preferences">{({ data }) => (<div>Current Theme: {data?.color ?? 'Default'}</div>)}</LocalStorageLoader>);}export default App;
Why This Is Powerful:
- Abstraction: UI doesn't care if data comes from API, LocalStorage, or Context
- Swappable: Change data source without touching UI components
- Testable: Easy to mock different data sources in tests
- Consistent: Same pattern works for all data sources
๐ Why This Makes You a Senior Dev
1. Decoupling
If the backend API changes from REST to GraphQL, you only update the DataSource. You don't touch the UserInfo UI.
2. Reusability
You wrote the "Loading..." logic once. You don't need to write if (!user) return <Spinner />in 50 different components.
3. Testing
You can test UserInfo by just passing { name: "Test" }. You don't need to mock fetch or set up a fake server just to test if the name renders bold.
This pattern scales with your application. As you add more features, you reuse the same patterns instead of writing new logic for each component. That's the mark of senior-level architecture.
๐ฏ Key Takeaways
When to Use This Pattern
- When you have data fetching logic that's repeated across components
- When you want to separate business logic from presentation
- When you need flexible, reusable data sources
- When building large-scale applications that need to scale
Best Practices
- Keep containers focused on data/logic only
- Keep presentational components pure (no side effects)
- Use TypeScript for better type safety with render props
- Handle loading and error states in the container
Remember: The Container/Presentational Pattern with Render Props is your secret weapon for building scalable React applications. It separates concerns, promotes reusability, and makes your codebase maintainable as it grows.