Episode 2 — React Frontend Architecture NextJS / 2.6 — Component Architecture Principles
2.6.a — Single Responsibility Principle
In one sentence: A component should do one thing, do it well, and be the only place you need to change when that one thing evolves.
Navigation: ← Overview · Next → Smart vs Dumb Components
Table of Contents
- What Is the Single Responsibility Principle?
- SRP in Software Engineering History
- Why SRP Matters in React
- Recognizing SRP Violations
- The "Reason to Change" Test
- Case Study: The God Component
- Splitting the God Component — Step by Step
- SRP for Data Fetching
- SRP for Business Logic
- SRP for Styling and Layout
- SRP for Side Effects
- When Splitting Goes Too Far
- SRP Decision Framework
- Real-World Architecture: E-Commerce Product Page
- SRP Across the Codebase
- Key Takeaways
1. What Is the Single Responsibility Principle?
The Single Responsibility Principle (SRP) is one of the five SOLID principles from object-oriented programming, coined by Robert C. Martin (Uncle Bob). The original formulation:
"A class should have only one reason to change."
In React terms, we translate this to:
"A component should have only one reason to change."
This doesn't mean a component can only do one tiny thing. It means a component should serve one cohesive purpose, and when requirements change, only changes to that purpose should require modifying that component.
The Cohesion Spectrum
┌──────────────────────────────────────────────────────────────────┐
│ COHESION SPECTRUM │
├──────────────────────────────────────────────────────────────────┤
│ │
│ LOW COHESION HIGH COHESION │
│ (God component) (Focused component) │
│ │
│ ┌─────────────┐ ┌──────────┐ │
│ │ UserProfile │ │ Avatar │ │
│ │ - fetch │ └──────────┘ │
│ │ - avatar │ ┌──────────┐ │
│ │ - bio │ ───► │ UserBio │ │
│ │ - posts │ └──────────┘ │
│ │ - settings │ ┌──────────┐ │
│ │ - theme │ │ PostList │ │
│ └─────────────┘ └──────────┘ │
│ ┌──────────┐ │
│ One reason to change? │ Settings │ │
│ NO — at least 6 reasons. └──────────┘ │
│ │
│ Each small component? │
│ YES — one reason each. │
│ │
└──────────────────────────────────────────────────────────────────┘
What "One Thing" Actually Means
| Level | What it means | Example |
|---|---|---|
| Too narrow | One HTML element | A component that only renders <h1> |
| Just right | One cohesive feature | A SearchBar that handles input + submit |
| Too broad | Multiple unrelated features | A Dashboard that fetches data, renders charts, handles settings |
The sweet spot: a component should be about one idea you can explain in a single sentence without using "and".
// ✅ One sentence, no "and"
// "SearchBar lets the user type a query and submit it"
// Wait — that has "and"! But typing + submitting are part of ONE cohesive behavior: searching.
// The "and" test fails when the ideas are UNRELATED.
// ❌ "UserDashboard displays the profile AND manages notifications AND shows analytics"
// Three unrelated domains forced into one component.
2. SRP in Software Engineering History
SRP didn't appear in a vacuum. Understanding its origins helps you understand why it's so important in modern UI development.
The Evolution of Code Organization
1960s ─── Spaghetti Code
│ Everything in one file. GOTOs everywhere.
│ Change one thing, break everything.
▼
1970s ─── Structured Programming (Dijkstra)
│ Functions, procedures, modules.
│ "Each function does one thing."
▼
1980s ─── Object-Oriented Programming
│ Encapsulation, inheritance, polymorphism.
│ Classes group related data + behavior.
▼
2000s ─── SOLID Principles (Robert C. Martin)
│ S — Single Responsibility
│ O — Open/Closed
│ L — Liskov Substitution
│ I — Interface Segregation
│ D — Dependency Inversion
▼
2013+ ─── Component-Based Architecture (React)
│ SRP applied to UI components.
│ Each component = one responsibility.
▼
2020s ─── Hooks + Server Components
SRP applied to logic (custom hooks)
and rendering boundaries (server vs client).
Why SRP Became Critical in React
In traditional MVC, you had:
- Model — data
- View — presentation
- Controller — logic
Each had a clear boundary. But in React, components contain all three. Without SRP, a single component easily becomes a mini-MVC framework on its own — fetching data, transforming it, rendering it, handling user interaction, managing side effects, and applying styles.
3. Why SRP Matters in React
Problem 1: Readability
// ❌ 400-line component
function ProductPage() {
// 50 lines of state
const [product, setProduct] = useState(null);
const [reviews, setReviews] = useState([]);
const [relatedProducts, setRelatedProducts] = useState([]);
const [cart, setCart] = useState([]);
const [selectedSize, setSelectedSize] = useState('');
const [selectedColor, setSelectedColor] = useState('');
const [quantity, setQuantity] = useState(1);
const [isWishlisted, setIsWishlisted] = useState(false);
const [showReviewForm, setShowReviewForm] = useState(false);
const [reviewText, setReviewText] = useState('');
const [reviewRating, setReviewRating] = useState(0);
const [activeImageIndex, setActiveImageIndex] = useState(0);
const [zoomLevel, setZoomLevel] = useState(1);
// ... 30 more lines of effects, handlers, calculations
// ... 300 lines of JSX
}
When a bug is reported in "review submission," you have to read the entire 400-line file to find the relevant code.
Problem 2: Testability
// ❌ To test the review form, you have to mount the ENTIRE ProductPage
// which requires mocking product data, cart state, image gallery, etc.
render(<ProductPage />);
// ✅ With SRP, test just the review form
render(<ReviewForm onSubmit={mockSubmit} />);
Problem 3: Reusability
// ❌ Can't reuse the image gallery without bringing the entire ProductPage
// ✅ With SRP, the ImageGallery works anywhere
<ImageGallery images={product.images} />
<ImageGallery images={user.photos} />
<ImageGallery images={article.figures} />
Problem 4: Collaboration
When multiple developers work on one large component, merge conflicts are constant. With SRP, each developer works on separate files.
Problem 5: Performance
// ❌ Any state change in the 400-line component re-renders EVERYTHING
// Typing in the review form re-renders the image gallery, price, related products...
// ✅ With SRP, only the ReviewForm re-renders when typing
The Benefits Table
| Benefit | Without SRP | With SRP |
|---|---|---|
| Finding code | Grep through 400-line file | Go to ReviewForm.jsx |
| Testing | Mock 15 dependencies | Mock 2 props |
| Reusing | Copy-paste + delete | Import |
| Reviewing PRs | "Changed 400 lines" | "Changed ReviewForm (80 lines)" |
| Performance | Everything re-renders | Only affected component re-renders |
| Onboarding | "Read the whole file" | "Each file is self-contained" |
4. Recognizing SRP Violations
Code Smells That Scream "Too Many Responsibilities"
Smell 1: Too Many State Variables
// 🚨 If a component has 7+ useState calls, it probably does too much
function Dashboard() {
const [users, setUsers] = useState([]);
const [posts, setPosts] = useState([]);
const [notifications, setNotifications] = useState([]);
const [analytics, setAnalytics] = useState(null);
const [selectedUser, setSelectedUser] = useState(null);
const [dateRange, setDateRange] = useState({ from: null, to: null });
const [chartType, setChartType] = useState('line');
const [isExporting, setIsExporting] = useState(false);
// ...
}
Smell 2: Too Many useEffect Calls
// 🚨 Each useEffect is often a separate concern
function Profile() {
useEffect(() => { /* fetch user data */ }, [userId]);
useEffect(() => { /* fetch user posts */ }, [userId]);
useEffect(() => { /* set up WebSocket for notifications */ }, []);
useEffect(() => { /* track page view analytics */ }, []);
useEffect(() => { /* update document title */ }, [user?.name]);
// Five effects = probably five concerns
}
Smell 3: Multiple <section> or Region Dividers in JSX
return (
<div>
{/* ---- Header Section ---- */}
<header>...</header>
{/* ---- Sidebar Section ---- */}
<aside>...</aside>
{/* ---- Main Content Section ---- */}
<main>...</main>
{/* ---- Footer Section ---- */}
<footer>...</footer>
{/* ---- Modal Section ---- */}
{showModal && <div className="modal">...</div>}
</div>
);
// If you need comment dividers, each section should probably be a component
Smell 4: Mixed Abstraction Levels
// 🚨 Some parts are high-level concepts, others are low-level DOM manipulation
function App() {
return (
<div>
<Navigation /> {/* High-level */}
<div className="flex gap-4 p-6"> {/* Low-level layout */}
<div className="w-64 bg-gray-100"> {/* Low-level styling */}
<Sidebar /> {/* High-level */}
</div>
<main className="flex-1"> {/* Low-level layout */}
<Routes> {/* High-level */}
<Route path="/" element={<Home />} />
</Routes>
</main>
</div>
</div>
);
}
// ✅ Better: Consistent abstraction level
function App() {
return (
<AppLayout>
<Navigation />
<Sidebar />
<MainContent>
<Routes>
<Route path="/" element={<Home />} />
</Routes>
</MainContent>
</AppLayout>
);
}
Smell 5: Long File
| Lines | Assessment |
|---|---|
| < 100 | Likely fine |
| 100–200 | Check if it can be split |
| 200–400 | Almost certainly violates SRP |
| 400+ | Definitely violates SRP |
These are guidelines, not rules. A 300-line component that does one complex thing (like a data table with sorting/filtering/pagination) might be fine if all 300 lines serve that one purpose.
Smell 6: The Name Requires "And" or "With"
❌ UserProfileAndSettings
❌ ProductListWithFiltersAndPagination
❌ DashboardAndAnalytics
✅ UserProfile
✅ ProductList (filters/pagination are children or hooks)
✅ Dashboard (analytics is a child component)
5. The "Reason to Change" Test
The most reliable way to check SRP: list all the reasons this component might need to change.
Step-by-Step Process
Step 1: List all things the component does
Step 2: For each thing, ask "If this requirement changes, do I edit this component?"
Step 3: If you get more than 2-3 reasons → split
Example: TodoApp
function TodoApp() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
const [input, setInput] = useState('');
// Fetch todos from API
useEffect(() => {
fetch('/api/todos').then(r => r.json()).then(setTodos);
}, []);
// Add todo
const addTodo = () => {
const newTodo = { id: Date.now(), text: input, done: false };
setTodos([...todos, newTodo]);
fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo) });
setInput('');
};
// Toggle todo
const toggleTodo = (id) => {
setTodos(todos.map(t => t.id === id ? { ...t, done: !t.done } : t));
};
// Delete todo
const deleteTodo = (id) => {
setTodos(todos.filter(t => t.id !== id));
};
// Filter
const filtered = todos.filter(t => {
if (filter === 'active') return !t.done;
if (filter === 'completed') return t.done;
return true;
});
return (
<div>
<h1>Todos</h1>
<input value={input} onChange={e => setInput(e.target.value)} />
<button onClick={addTodo}>Add</button>
<div>
<button onClick={() => setFilter('all')}>All</button>
<button onClick={() => setFilter('active')}>Active</button>
<button onClick={() => setFilter('completed')}>Completed</button>
</div>
<ul>
{filtered.map(todo => (
<li key={todo.id}>
<input type="checkbox" checked={todo.done} onChange={() => toggleTodo(todo.id)} />
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>×</button>
</li>
))}
</ul>
<p>{todos.filter(t => !t.done).length} items left</p>
</div>
);
}
Reasons to change:
| # | Reason | What changes |
|---|---|---|
| 1 | API endpoint changes | Fetch logic |
| 2 | Todo input needs validation | Input component |
| 3 | Filter UI redesign | Filter buttons |
| 4 | Todo item gets a priority field | Todo item rendering |
| 5 | Stats section gets more metrics | Footer area |
Five reasons → this component should be split into at least 3-4 smaller components.
6. Case Study: The God Component
Let's look at a realistic "God Component" — a dashboard that grew organically over months.
// ❌ THE GOD COMPONENT — 350+ lines
function AdminDashboard() {
// === USER DATA ===
const [users, setUsers] = useState([]);
const [userSearch, setUserSearch] = useState('');
const [userSort, setUserSort] = useState('name');
const [selectedUser, setSelectedUser] = useState(null);
// === ANALYTICS DATA ===
const [revenue, setRevenue] = useState(0);
const [pageViews, setPageViews] = useState(0);
const [conversions, setConversions] = useState(0);
const [dateRange, setDateRange] = useState('7d');
const [chartData, setChartData] = useState([]);
// === NOTIFICATION DATA ===
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
// === SETTINGS ===
const [theme, setTheme] = useState('light');
const [language, setLanguage] = useState('en');
// === FETCH ALL DATA ===
useEffect(() => {
Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/analytics?range=' + dateRange).then(r => r.json()),
fetch('/api/notifications').then(r => r.json()),
]).then(([userData, analyticsData, notifData]) => {
setUsers(userData);
setRevenue(analyticsData.revenue);
setPageViews(analyticsData.pageViews);
setConversions(analyticsData.conversions);
setChartData(analyticsData.chart);
setNotifications(notifData);
setUnreadCount(notifData.filter(n => !n.read).length);
});
}, [dateRange]);
// === NOTIFICATION WEBSOCKET ===
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/notifications');
ws.onmessage = (event) => {
const notif = JSON.parse(event.data);
setNotifications(prev => [notif, ...prev]);
setUnreadCount(prev => prev + 1);
};
return () => ws.close();
}, []);
// === USER SEARCH & SORT ===
const filteredUsers = useMemo(() => {
return users
.filter(u => u.name.toLowerCase().includes(userSearch.toLowerCase()))
.sort((a, b) => a[userSort].localeCompare(b[userSort]));
}, [users, userSearch, userSort]);
// === MARK NOTIFICATION READ ===
const markRead = (id) => {
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
setUnreadCount(prev => prev - 1);
fetch(`/api/notifications/${id}/read`, { method: 'PATCH' });
};
// === THEME TOGGLE ===
const toggleTheme = () => {
const next = theme === 'light' ? 'dark' : 'light';
setTheme(next);
document.body.className = next;
localStorage.setItem('theme', next);
};
return (
<div className={`dashboard ${theme}`}>
{/* --- Header with notifications --- */}
<header>
<h1>Admin Dashboard</h1>
<div className="notifications">
<button>🔔 {unreadCount}</button>
<div className="dropdown">
{notifications.slice(0, 5).map(n => (
<div key={n.id} className={n.read ? '' : 'unread'} onClick={() => markRead(n.id)}>
{n.message}
</div>
))}
</div>
</div>
<button onClick={toggleTheme}>{theme === 'light' ? '🌙' : '☀️'}</button>
</header>
{/* --- Analytics Section --- */}
<section className="analytics">
<div className="stat-cards">
<div className="card"><h3>Revenue</h3><p>${revenue.toLocaleString()}</p></div>
<div className="card"><h3>Page Views</h3><p>{pageViews.toLocaleString()}</p></div>
<div className="card"><h3>Conversions</h3><p>{conversions}%</p></div>
</div>
<select value={dateRange} onChange={e => setDateRange(e.target.value)}>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
{/* Chart rendering... */}
</section>
{/* --- User Management Section --- */}
<section className="users">
<input placeholder="Search users..." value={userSearch}
onChange={e => setUserSearch(e.target.value)} />
<select value={userSort} onChange={e => setUserSort(e.target.value)}>
<option value="name">Name</option>
<option value="email">Email</option>
<option value="role">Role</option>
</select>
<table>
<thead><tr><th>Name</th><th>Email</th><th>Role</th></tr></thead>
<tbody>
{filteredUsers.map(u => (
<tr key={u.id} onClick={() => setSelectedUser(u)}>
<td>{u.name}</td><td>{u.email}</td><td>{u.role}</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
);
}
Counting the Responsibilities
┌─────────────────────────────────────────────────┐
│ ADMIN DASHBOARD — RESPONSIBILITY MAP │
├─────────────────────────────────────────────────┤
│ │
│ 1. User data fetching & state │
│ 2. User search & filtering │
│ 3. User sorting │
│ 4. User table rendering │
│ 5. Analytics data fetching │
│ 6. Analytics stat cards rendering │
│ 7. Date range selection │
│ 8. Chart rendering │
│ 9. Notification fetching │
│ 10. Notification WebSocket │
│ 11. Notification dropdown rendering │
│ 12. Mark-as-read logic │
│ 13. Theme management │
│ 14. Overall layout │
│ │
│ 14 responsibilities = 14 reasons to change │
│ │
└─────────────────────────────────────────────────┘
7. Splitting the God Component — Step by Step
Step 1: Identify Responsibility Clusters
Group related state + logic + UI:
Cluster A: User Management
- users state, userSearch, userSort, selectedUser
- fetchUsers, filteredUsers
- search input, sort select, user table
Cluster B: Analytics
- revenue, pageViews, conversions, chartData, dateRange
- fetchAnalytics
- stat cards, date range select, chart
Cluster C: Notifications
- notifications, unreadCount
- WebSocket setup
- markRead
- notification bell + dropdown
Cluster D: Theme
- theme state, toggleTheme
- persisting to localStorage
Cluster E: Layout
- overall page structure (header, sections)
Step 2: Extract Components
// ✅ AFTER SPLITTING
// --- AdminDashboard.jsx (Layout only) ---
function AdminDashboard() {
return (
<ThemeProvider>
<div className="dashboard">
<DashboardHeader />
<main className="dashboard-content">
<AnalyticsPanel />
<UserManagement />
</main>
</div>
</ThemeProvider>
);
}
// --- DashboardHeader.jsx ---
function DashboardHeader() {
return (
<header>
<h1>Admin Dashboard</h1>
<NotificationBell />
<ThemeToggle />
</header>
);
}
// --- NotificationBell.jsx ---
function NotificationBell() {
const { notifications, unreadCount, markRead } = useNotifications();
return (
<div className="notifications">
<button>🔔 {unreadCount}</button>
<NotificationDropdown
notifications={notifications.slice(0, 5)}
onMarkRead={markRead}
/>
</div>
);
}
// --- useNotifications.js (custom hook) ---
function useNotifications() {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
// Fetch
useEffect(() => {
fetch('/api/notifications')
.then(r => r.json())
.then(data => {
setNotifications(data);
setUnreadCount(data.filter(n => !n.read).length);
});
}, []);
// WebSocket
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/notifications');
ws.onmessage = (event) => {
const notif = JSON.parse(event.data);
setNotifications(prev => [notif, ...prev]);
setUnreadCount(prev => prev + 1);
};
return () => ws.close();
}, []);
const markRead = (id) => {
setNotifications(prev =>
prev.map(n => n.id === id ? { ...n, read: true } : n)
);
setUnreadCount(prev => prev - 1);
fetch(`/api/notifications/${id}/read`, { method: 'PATCH' });
};
return { notifications, unreadCount, markRead };
}
// --- AnalyticsPanel.jsx ---
function AnalyticsPanel() {
const [dateRange, setDateRange] = useState('7d');
const { revenue, pageViews, conversions, chartData } = useAnalytics(dateRange);
return (
<section className="analytics">
<StatCards revenue={revenue} pageViews={pageViews} conversions={conversions} />
<DateRangeSelect value={dateRange} onChange={setDateRange} />
<AnalyticsChart data={chartData} />
</section>
);
}
// --- UserManagement.jsx ---
function UserManagement() {
const { users, isLoading } = useUsers();
const [search, setSearch] = useState('');
const [sort, setSort] = useState('name');
const filtered = useMemo(() => {
return users
.filter(u => u.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => a[sort].localeCompare(b[sort]));
}, [users, search, sort]);
return (
<section className="users">
<UserSearchBar value={search} onChange={setSearch} />
<UserSortSelect value={sort} onChange={setSort} />
<UserTable users={filtered} isLoading={isLoading} />
</section>
);
}
The Result: Before vs After
BEFORE (1 file, 350 lines): AFTER (10+ files, ~35 lines each):
┌───────────────────────┐ ┌─────────────────┐
│ AdminDashboard │ │ AdminDashboard │ (20 lines — layout only)
│ │ ├─────────────────┤
│ 14 responsibilities │ ───► │ DashboardHeader │ (15 lines)
│ 14 reasons to change│ │ NotificationBell │ (20 lines)
│ 350+ lines │ │ useNotifications │ (40 lines)
│ untestable │ │ AnalyticsPanel │ (20 lines)
│ unreusable │ │ useAnalytics │ (25 lines)
│ │ │ StatCards │ (15 lines)
└───────────────────────┘ │ UserManagement │ (30 lines)
│ useUsers │ (20 lines)
│ ThemeToggle │ (10 lines)
│ ThemeProvider │ (20 lines)
└─────────────────┘
8. SRP for Data Fetching
Data fetching is one of the most common SRP violations. Components should not know how data arrives — only what data they render.
Anti-Pattern: Fetching Inside UI Components
// ❌ The ProductCard knows about the API
function ProductCard({ productId }) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/products/${productId}`)
.then(r => {
if (!r.ok) throw new Error('Failed');
return r.json();
})
.then(setProduct)
.catch(setError)
.finally(() => setLoading(false));
}, [productId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
);
}
Problems:
- Can't reuse
ProductCardwith data from a different source (cache, WebSocket, prop) - Can't test the card without mocking
fetch - Loading/error handling is duplicated across every component that fetches
Solution 1: Custom Hook
// ✅ Separate concerns: hook handles data, component handles UI
function useProduct(productId) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/products/${productId}`)
.then(r => {
if (!r.ok) throw new Error('Failed');
return r.json();
})
.then(setProduct)
.catch(setError)
.finally(() => setLoading(false));
}, [productId]);
return { product, loading, error };
}
// Component is pure UI now
function ProductCard({ product }) {
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
);
}
// Parent orchestrates
function ProductPage({ productId }) {
const { product, loading, error } = useProduct(productId);
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return <ProductCard product={product} />;
}
Solution 2: Container Pattern
// Container (data) + Presentational (UI) separation
function ProductCardContainer({ productId }) {
const { product, loading, error } = useProduct(productId);
if (loading) return <ProductCardSkeleton />;
if (error) return <ErrorMessage error={error} />;
return <ProductCard product={product} />;
}
function ProductCard({ product }) {
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
);
}
The Separation
┌──────────────────────────────────────────┐
│ DATA LAYER │
│ │
│ useProduct() useUsers() useAnalytics()│
│ │ │ │ │
│ ▼ ▼ ▼ │
├──────────────────────────────────────────┤
│ ORCHESTRATION LAYER │
│ │
│ ProductPage UserPage Dashboard │
│ (loading/error/data routing) │
│ │ │ │ │
│ ▼ ▼ ▼ │
├──────────────────────────────────────────┤
│ UI LAYER │
│ │
│ ProductCard UserTable StatCards │
│ (pure rendering — no fetch, no effects)│
│ │
└──────────────────────────────────────────┘
9. SRP for Business Logic
Business logic (calculations, transformations, validations) should be extracted from components into utility functions or custom hooks.
Anti-Pattern: Business Logic in JSX
// ❌ Price calculation logic mixed with rendering
function CartSummary({ items }) {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const tax = subtotal * 0.08;
const shipping = subtotal > 50 ? 0 : 5.99;
const discount = items.some(i => i.category === 'sale')
? subtotal * 0.1
: 0;
const total = subtotal + tax + shipping - discount;
return (
<div>
<p>Subtotal: ${subtotal.toFixed(2)}</p>
<p>Tax: ${tax.toFixed(2)}</p>
<p>Shipping: {shipping === 0 ? 'FREE' : `$${shipping.toFixed(2)}`}</p>
{discount > 0 && <p>Discount: -${discount.toFixed(2)}</p>}
<p>Total: ${total.toFixed(2)}</p>
</div>
);
}
Solution: Extract Business Logic
// ✅ Pure function — testable without React
// utils/pricing.js
export function calculateCartPricing(items) {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const tax = subtotal * 0.08;
const shipping = subtotal > 50 ? 0 : 5.99;
const discount = items.some(i => i.category === 'sale') ? subtotal * 0.1 : 0;
const total = subtotal + tax + shipping - discount;
return { subtotal, tax, shipping, discount, total };
}
// Component is pure rendering
function CartSummary({ items }) {
const pricing = calculateCartPricing(items);
return (
<div>
<LineItem label="Subtotal" amount={pricing.subtotal} />
<LineItem label="Tax" amount={pricing.tax} />
<LineItem label="Shipping" amount={pricing.shipping} free={pricing.shipping === 0} />
{pricing.discount > 0 && (
<LineItem label="Discount" amount={-pricing.discount} />
)}
<LineItem label="Total" amount={pricing.total} bold />
</div>
);
}
Why this is better:
calculateCartPricingcan be tested with plain JavaScript — no React needed- It can be reused on the server side
- The component is trivially simple — just layout
Where Business Logic Should Live
| Type of Logic | Where It Goes | Example |
|---|---|---|
| Data transformation | Utility function | formatCurrency(), sortByDate() |
| Validation | Utility or schema | validateEmail(), zodSchema.parse() |
| Derived data | useMemo or utility | calculateTotal(items) |
| API calls | Custom hook or service | useProducts(), api.getProducts() |
| State machines | Custom hook or library | useAuthFlow(), XState machine |
| Business rules | Utility function | canUserEdit(user, post), getPriceAfterDiscount(price, coupon) |
10. SRP for Styling and Layout
Mixing layout concerns with feature logic is a subtle SRP violation.
Anti-Pattern: Layout + Feature Together
// ❌ ProductList handles both layout and product rendering
function ProductList({ products }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-4">
{products.map(p => (
<div key={p.id} className="bg-white rounded-lg shadow-md hover:shadow-lg
transition-shadow border border-gray-200 overflow-hidden">
<img className="w-full h-48 object-cover" src={p.image} alt={p.name} />
<div className="p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1">{p.name}</h3>
<p className="text-gray-600 text-sm mb-2">{p.description}</p>
<div className="flex justify-between items-center">
<span className="text-xl font-bold text-green-600">${p.price}</span>
<button className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
Add to Cart
</button>
</div>
</div>
</div>
))}
</div>
);
}
Solution: Separate Layout from Content
// ✅ Grid layout is reusable
function Grid({ children, cols = { sm: 1, md: 2, lg: 3, xl: 4 } }) {
return (
<div className={`grid grid-cols-${cols.sm} md:grid-cols-${cols.md}
lg:grid-cols-${cols.lg} xl:grid-cols-${cols.xl} gap-6 p-4`}>
{children}
</div>
);
}
// ✅ Card is a reusable visual container
function Card({ children, className = '' }) {
return (
<div className={`bg-white rounded-lg shadow-md hover:shadow-lg
transition-shadow border border-gray-200 overflow-hidden ${className}`}>
{children}
</div>
);
}
// ✅ ProductCard handles only product rendering
function ProductCard({ product, onAddToCart }) {
return (
<Card>
<img className="w-full h-48 object-cover" src={product.image} alt={product.name} />
<div className="p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1">{product.name}</h3>
<p className="text-gray-600 text-sm mb-2">{product.description}</p>
<div className="flex justify-between items-center">
<Price value={product.price} />
<AddToCartButton onClick={() => onAddToCart(product.id)} />
</div>
</div>
</Card>
);
}
// ✅ ProductList is just composition
function ProductList({ products, onAddToCart }) {
return (
<Grid cols={{ sm: 1, md: 2, lg: 3, xl: 4 }}>
{products.map(p => (
<ProductCard key={p.id} product={p} onAddToCart={onAddToCart} />
))}
</Grid>
);
}
Now:
Gridis reusable for any list of itemsCardis reusable for any card-style UIProductCardknows only about productsProductListis pure composition
11. SRP for Side Effects
Side effects (API calls, localStorage, analytics, WebSocket) often get stuffed into components. They should be isolated.
Anti-Pattern: Multiple Side Effects in One Component
// ❌ Component is a side-effect dumping ground
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// Side effect 1: Fetch user
useEffect(() => {
fetch(`/api/users/${userId}`).then(r => r.json()).then(setUser);
}, [userId]);
// Side effect 2: Track page view
useEffect(() => {
analytics.track('profile_view', { userId });
}, [userId]);
// Side effect 3: Update document title
useEffect(() => {
if (user) document.title = `${user.name} - Profile`;
return () => { document.title = 'App'; };
}, [user]);
// Side effect 4: Prefetch related data
useEffect(() => {
if (user) {
prefetchQuery(['user-posts', userId]);
prefetchQuery(['user-followers', userId]);
}
}, [user, userId]);
if (!user) return <Skeleton />;
return <ProfileView user={user} />;
}
Solution: Extract Each Side Effect
// ✅ Each concern in its own hook
// hooks/useDocumentTitle.js
function useDocumentTitle(title) {
useEffect(() => {
const prev = document.title;
if (title) document.title = title;
return () => { document.title = prev; };
}, [title]);
}
// hooks/usePageView.js
function usePageView(page, properties) {
useEffect(() => {
analytics.track('page_view', { page, ...properties });
}, [page, JSON.stringify(properties)]);
}
// hooks/usePrefetch.js
function usePrefetch(queries) {
useEffect(() => {
queries.forEach(q => prefetchQuery(q));
}, [JSON.stringify(queries)]);
}
// ✅ Component is clean
function UserProfile({ userId }) {
const { user, isLoading } = useUser(userId);
useDocumentTitle(user ? `${user.name} - Profile` : null);
usePageView('profile', { userId });
usePrefetch(user ? [['user-posts', userId], ['user-followers', userId]] : []);
if (isLoading) return <Skeleton />;
return <ProfileView user={user} />;
}
Benefits:
useDocumentTitleis reusable across any pageusePageViewstandardizes analytics trackingusePrefetchis a generic optimization utilityUserProfilereads like a declaration of intent
12. When Splitting Goes Too Far
SRP can be taken to an absurd extreme. Recognize when you've over-split.
Signs of Over-Splitting
Sign 1: Wrapper Components That Add Nothing
// ❌ Over-split: wrapper does nothing meaningful
function UserNameText({ name }) {
return <span className="user-name">{name}</span>;
}
function UserEmailText({ email }) {
return <span className="user-email">{email}</span>;
}
function UserRoleTag({ role }) {
return <span className="role-tag">{role}</span>;
}
// These are just styled text — not worth separate components
Sign 2: You Need to Open 10 Files to Understand One Feature
src/
features/
product/
ProductContainer.jsx ← data fetching
ProductPresenter.jsx ← props transformation
ProductView.jsx ← layout
ProductTitle.jsx ← just <h1>
ProductImage.jsx ← just <img>
ProductPrice.jsx ← just <span>
ProductDescription.jsx ← just <p>
ProductAddToCartButton.jsx ← just <button>
useProduct.js ← hook
productUtils.js ← helpers
Navigating this to understand the product card is harder than reading one 100-line file.
Sign 3: Props Are Just Passed Through
// ❌ Over-split: ProductInfo just passes props to children
function ProductInfo({ name, price, description, category, rating }) {
return (
<div>
<ProductTitle name={name} />
<ProductPrice price={price} />
<ProductDescription description={description} />
<ProductCategory category={category} />
<ProductRating rating={rating} />
</div>
);
}
// Each "child" is just rendering one prop — not worth the abstraction
The Right Balance
┌────────────────────────────────────────────────────────┐
│ SPLITTING SPECTRUM │
│ │
│ Under-split Sweet Spot Over-split │
│ (God component) (Nano components)│
│ │
│ 1 file, 500 lines 5-10 files, 50 files, │
│ 15 responsibilities 30-80 lines each 5 lines each │
│ │
│ ❌ Unreadable ✅ Clear ❌ Fragmented │
│ ❌ Untestable ✅ Testable ❌ Over-abstracted│
│ ❌ No reuse ✅ Reusable ❌ Indirection │
│ │
└────────────────────────────────────────────────────────┘
The Split-or-Not Checklist
Before splitting, ask:
| Question | If YES | If NO |
|---|---|---|
| Will it be reused elsewhere? | Split | Maybe not |
| Does it have its own state? | Split | Maybe not |
| Does it have its own effects? | Split | Maybe not |
| Is it > 100 lines? | Split | Probably not |
| Does it have a different rate of change? | Split | Keep together |
| Does splitting require lots of prop passing? | Don't split | Split |
| Can you name it meaningfully? | Split | Don't split |
13. SRP Decision Framework
Use this framework when you're unsure whether a component needs splitting.
The Framework
┌──────────────────────┐
│ Is the component │
│ > 150 lines? │
└──────────┬───────────┘
YES │ NO
┌──────────┘ └──────── Probably fine.
▼ Check state count.
┌──────────────────────┐
│ Does it have 5+ │
│ useState/useEffect? │
└──────────┬───────────┘
YES │ NO
┌──────────┘ └──────── Probably fine.
▼
┌──────────────────────┐
│ Can you group the │
│ state into 2+ clear │
│ clusters? │
└──────────┬───────────┘
YES │ NO
┌──────────┘ └──────── Might just be a complex
▼ component. Consider a
┌──────────────────────┐ useReducer instead.
│ SPLIT! │
│ │
│ 1. Extract hooks for │
│ each data cluster │
│ 2. Extract child │
│ components for │
│ each UI section │
│ 3. Keep parent as │
│ pure composition │
└───────────────────────┘
Quick Decision Table
| Scenario | Action |
|---|---|
| Component renders a list of complex items | Extract the list item into a component |
| Component fetches data and renders it | Extract data fetching into a hook |
| Component has a form section and a display section | Split into two components |
| Component has 3+ useEffect calls | Extract each into a custom hook |
| Component mixes layout and feature logic | Extract layout into a wrapper component |
| Component has complex calculations | Extract into utility functions |
14. Real-World Architecture: E-Commerce Product Page
Let's architect a real product page from scratch using SRP.
Requirements
- Product images with gallery
- Product info (name, price, description, reviews summary)
- Size/color selector
- Add to cart button with quantity
- Product reviews section
- Related products
- Recently viewed tracking
Architecture Diagram
┌─────────────────────────────────────────────────┐
│ ProductPage │
│ (orchestrator) │
│ │
│ ┌─────────────────────┐ ┌────────────────────┐ │
│ │ ImageGallery │ │ ProductInfo │ │
│ │ ┌───────────────┐ │ │ ┌──────────────┐ │ │
│ │ │ MainImage │ │ │ │ ProductTitle │ │ │
│ │ │ ThumbnailList │ │ │ │ ProductPrice │ │ │
│ │ └───────────────┘ │ │ │ RatingStars │ │ │
│ └─────────────────────┘ │ │ Description │ │ │
│ │ └──────────────┘ │ │
│ ┌─────────────────────┐ │ ┌──────────────┐ │ │
│ │ VariantSelector │ │ │ AddToCart │ │ │
│ │ ┌───────────────┐ │ │ │ ┌──────────┐ │ │ │
│ │ │ SizeSelector │ │ │ │ │ Quantity │ │ │ │
│ │ │ ColorSelector │ │ │ │ │ CartBtn │ │ │ │
│ │ └───────────────┘ │ │ │ └──────────┘ │ │ │
│ └─────────────────────┘ │ └──────────────┘ │ │
│ └────────────────────┘ │
│ ┌──────────────────────────────────────────┐ │
│ │ ReviewsSection │ │
│ │ ┌─────────────┐ ┌──────────────────┐ │ │
│ │ │ ReviewStats │ │ ReviewList │ │ │
│ │ │ (avg, dist) │ │ ┌────────────┐ │ │ │
│ │ └─────────────┘ │ │ ReviewCard │ │ │ │
│ │ ┌─────────────┐ │ └────────────┘ │ │ │
│ │ │ ReviewForm │ └──────────────────┘ │ │
│ │ └─────────────┘ │ │
│ └──────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────┐ │
│ │ RelatedProducts │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
HOOKS:
useProduct(id) ──── product data + loading/error
useReviews(id) ──── reviews + pagination + submit
useCart() ───────── cart operations
useRecentlyViewed() ── tracking + list
Implementation Skeleton
// pages/ProductPage.jsx — ORCHESTRATOR ONLY
function ProductPage() {
const { id } = useParams();
const { product, isLoading, error } = useProduct(id);
useRecentlyViewed(id); // side effect: track viewing
if (isLoading) return <ProductPageSkeleton />;
if (error) return <ErrorPage error={error} />;
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<ImageGallery images={product.images} />
<div>
<ProductInfo product={product} />
<VariantSelector variants={product.variants} />
<AddToCart productId={product.id} price={product.price} />
</div>
</div>
<ReviewsSection productId={product.id} />
<RelatedProducts categoryId={product.categoryId} currentId={product.id} />
</div>
);
}
Each component here has exactly one responsibility:
ProductPage— orchestrates layout and data loadingImageGallery— displays product imagesProductInfo— shows product detailsVariantSelector— handles size/color selectionAddToCart— manages cart interactionReviewsSection— everything about reviewsRelatedProducts— shows similar products
15. SRP Across the Codebase
SRP doesn't just apply to components. It applies to every layer.
Files and Folders
src/
components/ ← Reusable UI components (SRP: rendering)
Button.jsx
Card.jsx
Modal.jsx
features/ ← Feature-specific components (SRP: one feature each)
product/
ProductPage.jsx
ProductCard.jsx
ImageGallery.jsx
cart/
CartPage.jsx
CartItem.jsx
CartSummary.jsx
hooks/ ← Custom hooks (SRP: one concern each)
useProduct.js ← fetching product data
useCart.js ← cart operations
useAuth.js ← auth state
useDebounce.js ← debounce utility
utils/ ← Pure functions (SRP: one calculation each)
pricing.js ← price calculations
validation.js ← form validation rules
formatting.js ← date/currency formatting
services/ ← API layer (SRP: one resource each)
productApi.js ← /api/products endpoints
userApi.js ← /api/users endpoints
cartApi.js ← /api/cart endpoints
contexts/ ← React contexts (SRP: one global concern each)
AuthContext.jsx ← authentication state
ThemeContext.jsx ← theme/dark mode
CartContext.jsx ← shopping cart
Hook SRP
// ❌ One hook doing everything
function useApp() {
const [user, setUser] = useState(null);
const [cart, setCart] = useState([]);
const [theme, setTheme] = useState('light');
// 100 lines of mixed logic
}
// ✅ Each hook has one responsibility
function useAuth() { /* user authentication only */ }
function useCart() { /* shopping cart only */ }
function useTheme() { /* theme management only */ }
Utility Function SRP
// ❌ One utility file with 50 functions
// utils/helpers.js — 1000 lines, everything dumped here
// ✅ Organized by domain
// utils/pricing.js
export function calculateSubtotal(items) { ... }
export function calculateTax(subtotal, rate) { ... }
export function applyDiscount(subtotal, coupon) { ... }
// utils/formatting.js
export function formatCurrency(amount, currency) { ... }
export function formatDate(date, format) { ... }
export function formatPhoneNumber(phone) { ... }
// utils/validation.js
export function validateEmail(email) { ... }
export function validatePassword(password) { ... }
export function validateCreditCard(number) { ... }
16. Key Takeaways
-
SRP = one reason to change, not "one thing." A
SearchBarthat handles input + submit is fine — they're one cohesive concern. -
Use the "reason to change" test: List all reasons a component might change. More than 3 → split.
-
Watch for code smells: 7+ state variables, 3+ useEffects, comment dividers, mixed abstraction levels, "and" in the name.
-
Separate concerns into layers: Data fetching → hooks, business logic → utilities, side effects → hooks, UI → components.
-
Don't over-split: If a component is < 100 lines and has one cohesive purpose, it's fine. Opening 10 files for one feature is worse than one 100-line file.
-
Apply SRP across the codebase: Files, folders, hooks, utilities, services — not just components.
-
The sweet spot: 5-10 files of 30-80 lines each, not 1 file of 500 lines, not 50 files of 5 lines.
Explain-It Challenge
-
Explain SRP to a restaurant owner: How would you explain the "single responsibility principle" using the analogy of a restaurant kitchen (chef, sous chef, dishwasher, host)?
-
The "and" test: Take a component from a project you've worked on and describe what it does in one sentence. Does the sentence contain "and" between unrelated concepts? How would you split it?
-
Draw the boundary: Given a
ChatRoomcomponent that handles message list display, message input, typing indicators, and online user list — identify the responsibility clusters and propose an architecture with component names.
Navigation: ← Overview · Next → Smart vs Dumb Components