DEV Community

Munna Thakur
Munna Thakur

Posted on

SOLID Principles in React: The Complete Guide Nobody Told You About

You've heard "write clean code." You've heard "follow best practices." But nobody sat down and told you — concretely — what that actually means when you're staring at a 400-line React component at 11pm. This article is that conversation.


Table of Contents

  1. What is SOLID and Why Should You Care?
  2. S — Single Responsibility Principle
  3. O — Open/Closed Principle
  4. L — Liskov Substitution Principle
  5. I — Interface Segregation Principle
  6. D — Dependency Inversion Principle
  7. SOLID vs MVC vs Feature-Based Architecture
  8. Advantages and Drawbacks
  9. When to Use — When to Skip
  10. Key Takeaways

What is SOLID and Why Should You Care?

SOLID is a set of 5 design principles that make your code easier to maintain, test, extend, and understand.

Letter Principle One Line
S Single Responsibility One class, one job
O Open/Closed Extend without modifying
L Liskov Substitution Replace without breaking
I Interface Segregation Give only what's needed
D Dependency Inversion Depend on abstractions

These aren't React-specific rules. They're universal software design principles — but they apply beautifully to React's component and hook model.

SOLID is NOT a replacement for MVC or Feature-Based Architecture

Think of it like three layers:

Feature-Based Architecture  →  How you organize folders
MVC / MVVM Pattern          →  How you split code inside each feature
SOLID Principles            →  How you write code inside each file
Enter fullscreen mode Exit fullscreen mode

They work together. SOLID is the innermost layer — the rules that govern the quality of your code at the line-by-line level.


S — Single Responsibility Principle

The Rule

A class, component, or function should have only one reason to change.

The Restaurant Analogy

In a good restaurant, the chef cooks. The waiter serves. The billing desk handles payments. Nobody does anyone else's job.

Now imagine a chef who also takes orders, handles billing, cleans tables, and manages suppliers. He's overwhelmed, makes mistakes, and when the billing system changes — the chef has to stop cooking to deal with it.

That chef is your Fat Component.

The Problem: What a Fat Component Looks Like

// ❌ UserProfile.jsx — the "monster" file
function UserProfile({ userId }) {

  // 1. Fetching data — not this component's job
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(setUser);
  }, [userId]);

  // 2. Validation logic — not this component's job
  const isValidEmail = (email) => email.includes('@') && email.includes('.');

  // 3. Date formatting — not this component's job
  const formatDate = (date) => new Date(date).toLocaleDateString('en-IN');

  // 4. Business rule — not this component's job
  const isPremium = user?.plan === 'premium';

  // 5. Analytics — definitely not this component's job
  useEffect(() => {
    analytics.track('profile_viewed', { userId });
  }, [userId]);

  // 6. Finally, the actual rendering
  return (
    <div>
      <h2>{user?.name}</h2>
      <p>{formatDate(user?.createdAt)}</p>
      {isPremium && <span>Premium Member</span>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This component does six things. Changing the API URL, the date format, the analytics SDK, or the UI — all require opening the same file. One change can break five unrelated things.

The Fix: One File, One Job

Split it across four focused files:

userApi.js — only HTTP calls

export const userApi = {
  getById: (id) =>
    fetch(`/api/users/${id}`).then(res => {
      if (!res.ok) throw new Error('User not found');
      return res.json();
    }),
};
Enter fullscreen mode Exit fullscreen mode

formatters.js — only formatting

export const formatDate = (dateStr) =>
  new Date(dateStr).toLocaleDateString('en-IN');

export const isValidEmail = (email) =>
  email.includes('@') && email.includes('.');
Enter fullscreen mode Exit fullscreen mode

useUser.js — only state and side effects

import { userApi } from '../api/userApi';
import { formatDate } from '../utils/formatters';

export function useUser(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    userApi.getById(userId)
      .then(setUser)
      .catch(e => setError(e.message))
      .finally(() => setLoading(false));

    analytics.track('profile_viewed', { userId });
  }, [userId]);

  return { user, loading, error };
}
Enter fullscreen mode Exit fullscreen mode

UserProfile.jsx — only rendering

import { useUser } from '../hooks/useUser';
import { formatDate } from '../utils/formatters';

function UserProfile({ userId }) {
  const { user, loading, error } = useUser(userId);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage message={error} />;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{formatDate(user.createdAt)}</p>
      {user.plan === 'premium' && <span>Premium Member</span>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now if the API changes — only userApi.js. Date format changes — only formatters.js. UI redesign — only UserProfile.jsx. Nothing else is touched.

The "AND" Test

If you need the word "AND" to describe what a file does — SRP is broken.

"This file fetches data AND validates it AND formats it AND renders it"

Every "AND" is a signal to split.

Testing Becomes Trivial

// Test formatters in isolation — no component, no API, no mocks
import { isValidEmail, formatDate } from './formatters';

test('validates email correctly', () => {
  expect(isValidEmail('test@gmail.com')).toBe(true);
  expect(isValidEmail('notanemail')).toBe(false);
});
Enter fullscreen mode Exit fullscreen mode

Three lines. No setup. No mocking. No React rendering. That's the power of SRP.


O — Open/Closed Principle

The Rule

Software should be open for extension, but closed for modification.

Add new features without changing existing, working code.

The Smartphone Analogy

You can install new apps on your phone without modifying the OS. The OS is closed for modification (you can't change the core), but open for extension (you can add apps).

The Problem: The Never-Ending if-else

// ❌ Every new button type means opening this file and editing it
function Button({ type, label }) {
  if (type === 'primary') {
    return <button style={{ background: 'blue' }}>{label}</button>;
  }
  if (type === 'danger') {
    return <button style={{ background: 'red' }}>{label}</button>;
  }
  if (type === 'success') {
    return <button style={{ background: 'green' }}>{label}</button>;
  }
  // Adding "warning"? Open this file again. Risk breaking primary/danger/success.
}
Enter fullscreen mode Exit fullscreen mode

Every new type means touching tested, working code. One typo breaks everything.

The Fix: Configuration Over Conditions

// ✅ New type = add one line to the config object. Component never changes.
const buttonStyles = {
  primary: { background: '#185FA5', color: '#fff' },
  danger:  { background: '#A32D2D', color: '#fff' },
  success: { background: '#3B6D11', color: '#fff' },
  warning: { background: '#854F0B', color: '#fff' },
  // Add "ghost" tomorrow? Just add it here. Button component stays closed.
};

function Button({ type = 'primary', label, onClick }) {
  return (
    <button style={buttonStyles[type]} onClick={onClick}>
      {label}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Real Production Example: Payment System

// ❌ OCP violation — every new payment method opens this function
function processPayment(type, data) {
  if (type === 'card') { return handleCard(data); }
  if (type === 'upi') { return handleUpi(data); }
  if (type === 'paypal') { return handlePaypal(data); }
  // Adding crypto? Open this file. Risk breaking card/UPI/PayPal.
}

// ✅ OCP followed — new payment method = new entry in the map
const paymentHandlers = {
  card:   handleCard,
  upi:    handleUpi,
  paypal: handlePaypal,
};

function processPayment(type, data) {
  const handler = paymentHandlers[type];
  if (!handler) throw new Error(`Unknown payment type: ${type}`);
  return handler(data);
}

// Adding crypto tomorrow:
paymentHandlers.crypto = handleCrypto;
// processPayment() function never touched. ✅
Enter fullscreen mode Exit fullscreen mode

The Golden Rule for OCP

"If you find yourself opening the same file again and again to add new features — you are violating OCP."


L — Liskov Substitution Principle

The Rule

If S is a subtype of T, objects of type T may be replaced with objects of type S without breaking the program.

In plain language: replace a parent with its child — nothing should break.

The Vehicle Analogy

A car has start() and stop(). A taxi is a car — it also has start() and stop() with the same behavior. You can swap a car for a taxi in any system that expects a car.

But a bicycle doesn't have an engine. If you try to call start() on a bicycle where a car is expected — it breaks. That's an LSP violation.

The Problem: Broken Contract

// ❌ LSP violation — GraphQL service changes the method signature
class RestUserService {
  getUser(id) {
    return fetch(`/api/users/${id}`).then(r => r.json());
  }
}

class GraphQLUserService {
  getUser(id, authToken) { // ← Extra parameter! Contract is broken.
    return graphqlClient.query(USER_QUERY, { id, authToken });
  }
}

// Now the component must know WHICH service it's using:
service.getUser(id);          // Works for REST
service.getUser(id, token);   // Needed for GraphQL
// You can't swap them — LSP is violated.
Enter fullscreen mode Exit fullscreen mode

The Fix: Same Contract, Different Implementation

// ✅ Both services honor the same interface
class RestUserService {
  getUser(id) {
    return fetch(`/api/users/${id}`).then(r => r.json());
  }
}

class GraphQLUserService {
  getUser(id) {
    // Auth is handled internally — not leaked to the caller
    return graphqlClient.query(USER_QUERY, { id });
  }
}

// The hook doesn't care which service it gets — both are interchangeable
function useUser(id, service) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    service.getUser(id).then(setUser);
  }, [id]);

  return user;
}

// Both work without changing a single line in the hook or component:
const user1 = useUser(1, new RestUserService());
const user2 = useUser(1, new GraphQLUserService());
Enter fullscreen mode Exit fullscreen mode

Real Use Case: Environment-Based Switching

// Development uses mock data. Production uses real API.
// The component never knows the difference.

const userService =
  process.env.NODE_ENV === 'development'
    ? new MockUserService()   // returns fake data instantly
    : new ApiUserService();   // real network calls

// Inject it — the component doesn't care which one it gets
function App() {
  return <UserList service={userService} />;
}
Enter fullscreen mode Exit fullscreen mode

LSP Violation Checklist

Your code violates LSP if a child/subtype:

  • Requires extra parameters the parent didn't
  • Returns a different shape of data
  • Throws errors the parent never threw
  • Ignores props/methods the parent relied on

I — Interface Segregation Principle

The Rule

Clients should not be forced to depend on interfaces they do not use.

In React: don't give a component more than it needs.

The Waiter Analogy

A waiter needs to know the menu and how to take orders. They do NOT need to know the kitchen's inventory, the supplier's delivery schedule, or the owner's bank account.

Give people exactly what they need. Nothing more.

The Problem: The "Full Object" Trap

// ❌ UserCard receives the entire user object
function UserCard({ user }) {
  // Only uses name and email
  return <div>{user.name}{user.email}</div>;
}

// The full user object that gets passed:
// {
//   id, name, email, password, billingInfo,
//   adminNotes, tokens, createdAt, updatedAt,
//   preferences, internalScore, ...
// }

<UserCard user={entireUserObject} /> // ❌
Enter fullscreen mode Exit fullscreen mode

Three real problems this causes:

1. Unnecessary re-renders

React re-renders UserCard whenever user changes. If user.password or user.billingInfo changes — the card re-renders even though it doesn't use those fields.

2. Security risk

password and tokens are now in the component's props. Any logging, debugging, or prop-spreading could expose them.

3. Tight coupling

If the user object structure changes — even fields UserCard doesn't use — you might break the component.

The Fix: Pass Only What's Needed

// ✅ UserCard receives only what it actually uses
function UserCard({ name, email }) {
  return <div>{name}{email}</div>;
}

// Clear, safe, minimal
<UserCard name={user.name} email={user.email} /> // ✅
Enter fullscreen mode Exit fullscreen mode

Now UserCard only re-renders when name or email change. It never sees password. If the user object grows 50 more fields — this component is completely unaffected.

ISP with Hooks: Return Only What's Needed

// ❌ Bad hook — returns everything
function useUser() {
  return {
    name: 'Munna',
    email: 'munna@example.com',
    posts: [],           // ← ProfilePage needs this
    followers: [],       // ← FollowersPage needs this
    billingInfo: {},     // ← BillingPage needs this
    settings: {},        // ← SettingsPage needs this
  };
}

// UserCard only needs name — but gets everything
const user = useUser();
return <p>{user.name}</p>;

// ✅ Better — targeted hooks
function useUserBasic() {
  return { name: 'Munna', email: 'munna@example.com' };
}

function useUserPosts(userId) {
  // fetches only posts
}

function useUserFollowers(userId) {
  // fetches only followers
}
Enter fullscreen mode Exit fullscreen mode

The Selector Pattern (Production Level)

When using Zustand or Redux, selectors are ISP in action:

// ❌ Component gets the entire store slice
const { name, email, posts, followers, billing } = useUserStore();

// ✅ Component gets exactly what it needs
const name = useUserStore(state => state.name);
Enter fullscreen mode Exit fullscreen mode

D — Dependency Inversion Principle

The Rule

High-level modules should not depend on low-level modules. Both should depend on abstractions.

The Phone Charger Analogy

Your phone doesn't connect directly to the power plant. There's a charger, a cable, and a socket — all abstractions between them.

You can change the charger (USB-C to MagSafe). You can travel to a different country (different socket). Your phone works every time because it depends on the abstraction (power delivery), not the implementation (specific power plant).

The Problem: Direct Dependency

// ❌ Component directly depends on fetch
function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch('/api/users')  // ← direct dependency on the network layer
      .then(r => r.json())
      .then(setUsers);
  }, []);

  return users.map(u => <p key={u.id}>{u.name}</p>);
}
Enter fullscreen mode Exit fullscreen mode

Problems:

  1. To test this, you must mock fetch globally — a messy, fragile approach
  2. Want to switch from REST to GraphQL? Modify this component
  3. Want to add caching? Modify this component
  4. Want to use mock data in dev? Modify this component

Every external change forces a component change.

The Fix: Depend on an Abstraction (Hook)

// Step 1: Abstraction — the hook
function useUsers() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/users')
      .then(r => r.json())
      .then(setUsers)
      .finally(() => setLoading(false));
  }, []);

  return { users, loading };
}

// Step 2: Component depends on the hook (abstraction), not fetch (implementation)
function UserList() {
  const { users, loading } = useUsers(); // ← depends on abstraction ✅

  if (loading) return <p>Loading...</p>;
  return users.map(u => <p key={u.id}>{u.name}</p>);
}
Enter fullscreen mode Exit fullscreen mode

Making It Testable with Injection

// The hook is injected as a prop with a default value
function UserList({ useUsersHook = useUsers }) {
  const { users, loading } = useUsersHook();

  if (loading) return <p>Loading...</p>;
  return users.map(u => <p key={u.id}>{u.name}</p>);
}

// In production — uses real hook automatically
<UserList />

// In tests — inject mock, no network calls
<UserList useUsersHook={() => ({ users: [{ id: 1, name: 'Munna' }], loading: false })} />
Enter fullscreen mode Exit fullscreen mode

Full Service Layer (Advanced DIP)

For larger projects, add a service layer between the hook and the API:

// Low-level: API layer (implementation detail)
class ApiUserService {
  getUsers() {
    return fetch('/api/users').then(r => r.json());
  }
}

class GraphQLUserService {
  getUsers() {
    return graphqlClient.query(USERS_QUERY);
  }
}

class MockUserService {
  getUsers() {
    return Promise.resolve([{ id: 1, name: 'Test User' }]);
  }
}

// Mid-level: Hook depends on service (abstraction)
function useUsers(service) {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    service.getUsers().then(setUsers);
  }, []);

  return users;
}

// High-level: Component depends on hook (abstraction)
function UserList() {
  const service = new ApiUserService(); // swap this without touching UserList
  const users = useUsers(service);

  return users.map(u => <p key={u.id}>{u.name}</p>);
}
Enter fullscreen mode Exit fullscreen mode

Switch from REST to GraphQL? Change one line — new ApiUserService()new GraphQLUserService(). UserList never knows.


SOLID vs MVC vs Feature-Based Architecture

These three are often confused. They're not competing — they operate at different levels:

┌─────────────────────────────────────────────────────────┐
│  Feature-Based Architecture                              │
│  (How folders are organized)                             │
│  /features/auth, /features/todos, /shared               │
│                                                          │
│  ┌───────────────────────────────────────────────────┐   │
│  │  MVC / MVVM Pattern                               │   │
│  │  (How code is split inside each feature)          │   │
│  │  API → Service → Hook → Component                 │   │
│  │                                                   │   │
│  │  ┌─────────────────────────────────────────────┐  │   │
│  │  │  SOLID Principles                           │  │   │
│  │  │  (How code is written inside each file)     │  │   │
│  │  │  SRP, OCP, LSP, ISP, DIP                   │  │   │
│  │  └─────────────────────────────────────────────┘  │   │
│  └───────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
What it answers
Feature-Based Where do files live?
MVC/MVVM How is logic split within a feature?
SOLID How is each individual file written?

You don't choose between them. You use all three together.


Advantages and Drawbacks

Advantages

1. Testability
Each unit has a single, clear job. Testing is isolated, fast, and doesn't require complex setup.

2. Maintainability
Bug in email validation? Look in formatters.js. Bug in data fetching? Look in the API layer. The blast radius of any problem is contained.

3. Reusability
A formatDate function written with SRP can be used in 20 components. An ApiUserService written with DIP can be swapped for a mock in tests.

4. Scalability
Add new features (OCP), swap implementations (LSP), inject dependencies (DIP) — without touching working code.

5. Team-friendliness
New developer joins? File names tell them exactly where everything lives. No archaeology required.

Drawbacks (Being Honest)

1. More files
A component that was one file becomes five. For small projects, this feels like overkill.

2. Navigation overhead
"Where is that function?" now requires jumping between files instead of scrolling.

3. Learning curve
Teams new to these principles need time to understand the patterns before they can move fast.

4. Risk of over-engineering
Applied too early or too aggressively, SOLID creates complexity without benefit. A todo app doesn't need a UserServiceFactory.

5. Boilerplate
Service classes, interfaces, injection patterns — they add lines before they save lines.


When to Use — When to Skip

Use SOLID when:

  • Your team has 2+ developers
  • The project will last more than 3 months
  • Multiple features share the same logic
  • Testing is a priority
  • The codebase is already starting to feel painful to change

Skip SOLID (for now) when:

  • You're building a quick MVP or prototype
  • You're working solo on a 2-3 page app
  • The deadline is tomorrow
  • The feature is genuinely one-off with no reuse
  • The team isn't familiar with the patterns yet — introduce one principle at a time

The Rule of Three

A good practical rule: the first time you write something, just write it. The second time you write something similar, note it. The third time, refactor to a shared abstraction.

Don't apply SOLID to code you've written once. Apply it when pain becomes real.


Key Takeaways

S — Single Responsibility: One file, one job. If you need "AND" to describe it — split it.

O — Open/Closed: Use configuration maps instead of growing if-else chains. Adding features shouldn't mean editing working code.

L — Liskov Substitution: Keep the same contract across implementations. If you can swap REST for GraphQL without changing the component — you're doing it right.

I — Interface Segregation: Pass only what's needed. More props doesn't mean more flexibility — it means more coupling and more re-renders.

D — Dependency Inversion: Components should depend on hooks. Hooks should depend on services. Services should depend on the API layer. Never jump levels.


One Paragraph to Remember

MVC tells you to split code into layers. Feature-Based Architecture tells you to organize those layers by feature. SOLID tells you how to write the code inside each layer so it's clean, testable, and changeable. They're not alternatives — they're nested levels of the same discipline. Master all three, and the 600-line "Monster" component becomes a memory.


What's Next?

If this article helped, the logical next read is:

  • BFF (Backend For Frontend) — how to stop the frontend from making 5 API calls where 1 would do
  • React Testing Library + SOLID — how clean architecture makes testing feel effortless
  • Zustand + DIP — how modern state management is just Dependency Inversion with a nicer API

Found this useful? Drop a comment with which principle clicked for you — or which one you're still confused about. I read every one.


Tags: #react #javascript #webdev #cleancode #architecture #solid #frontend #beginners #programming #softwaredevelopment

Top comments (0)