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

  1. What Is the Single Responsibility Principle?
  2. SRP in Software Engineering History
  3. Why SRP Matters in React
  4. Recognizing SRP Violations
  5. The "Reason to Change" Test
  6. Case Study: The God Component
  7. Splitting the God Component — Step by Step
  8. SRP for Data Fetching
  9. SRP for Business Logic
  10. SRP for Styling and Layout
  11. SRP for Side Effects
  12. When Splitting Goes Too Far
  13. SRP Decision Framework
  14. Real-World Architecture: E-Commerce Product Page
  15. SRP Across the Codebase
  16. 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

LevelWhat it meansExample
Too narrowOne HTML elementA component that only renders <h1>
Just rightOne cohesive featureA SearchBar that handles input + submit
Too broadMultiple unrelated featuresA 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

BenefitWithout SRPWith SRP
Finding codeGrep through 400-line fileGo to ReviewForm.jsx
TestingMock 15 dependenciesMock 2 props
ReusingCopy-paste + deleteImport
Reviewing PRs"Changed 400 lines""Changed ReviewForm (80 lines)"
PerformanceEverything re-rendersOnly 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

LinesAssessment
< 100Likely fine
100–200Check if it can be split
200–400Almost 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:

#ReasonWhat changes
1API endpoint changesFetch logic
2Todo input needs validationInput component
3Filter UI redesignFilter buttons
4Todo item gets a priority fieldTodo item rendering
5Stats section gets more metricsFooter 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:

  1. Can't reuse ProductCard with data from a different source (cache, WebSocket, prop)
  2. Can't test the card without mocking fetch
  3. 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:

  1. calculateCartPricing can be tested with plain JavaScript — no React needed
  2. It can be reused on the server side
  3. The component is trivially simple — just layout

Where Business Logic Should Live

Type of LogicWhere It GoesExample
Data transformationUtility functionformatCurrency(), sortByDate()
ValidationUtility or schemavalidateEmail(), zodSchema.parse()
Derived datauseMemo or utilitycalculateTotal(items)
API callsCustom hook or serviceuseProducts(), api.getProducts()
State machinesCustom hook or libraryuseAuthFlow(), XState machine
Business rulesUtility functioncanUserEdit(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:

  • Grid is reusable for any list of items
  • Card is reusable for any card-style UI
  • ProductCard knows only about products
  • ProductList is 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:

  • useDocumentTitle is reusable across any page
  • usePageView standardizes analytics tracking
  • usePrefetch is a generic optimization utility
  • UserProfile reads 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:

QuestionIf YESIf NO
Will it be reused elsewhere?SplitMaybe not
Does it have its own state?SplitMaybe not
Does it have its own effects?SplitMaybe not
Is it > 100 lines?SplitProbably not
Does it have a different rate of change?SplitKeep together
Does splitting require lots of prop passing?Don't splitSplit
Can you name it meaningfully?SplitDon'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

ScenarioAction
Component renders a list of complex itemsExtract the list item into a component
Component fetches data and renders itExtract data fetching into a hook
Component has a form section and a display sectionSplit into two components
Component has 3+ useEffect callsExtract each into a custom hook
Component mixes layout and feature logicExtract layout into a wrapper component
Component has complex calculationsExtract 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 loading
  • ImageGallery — displays product images
  • ProductInfo — shows product details
  • VariantSelector — handles size/color selection
  • AddToCart — manages cart interaction
  • ReviewsSection — everything about reviews
  • RelatedProducts — 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

  1. SRP = one reason to change, not "one thing." A SearchBar that handles input + submit is fine — they're one cohesive concern.

  2. Use the "reason to change" test: List all reasons a component might change. More than 3 → split.

  3. Watch for code smells: 7+ state variables, 3+ useEffects, comment dividers, mixed abstraction levels, "and" in the name.

  4. Separate concerns into layers: Data fetching → hooks, business logic → utilities, side effects → hooks, UI → components.

  5. 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.

  6. Apply SRP across the codebase: Files, folders, hooks, utilities, services — not just components.

  7. 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

  1. 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)?

  2. 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?

  3. Draw the boundary: Given a ChatRoom component 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