Applying SOLID Principles in React: A Practical Guide

javascriptreact
Elvis Duru

Elvis Duru / February 28, 2025 

12 min read --- views

SOLID principles form the foundation of clean, scalable, and maintainable software. Though these principles originated in object-oriented programming, they translate effectively to frontend development, particularly in React's component-driven architecture.

In this article, we'll examine each SOLID principle through real-world React examples, leveraging Composition, Props, Hooks, and Context API to implement them effectively. We'll explore best practices to ensure your React components remain efficient and adaptable.


1. Single Responsibility Principle (SRP) → "Singular Responsibilities"

A component, hook, or function should have only one reason to change. In React, this means each piece of code should encapsulate one responsibility, such as rendering a specific UI, managing a single state, or handling a distinct logic flow

🚀 Why It Matters in React

Components with singular responsibilities are easier to reuse, test, and debug. SRP prevents "god components" that become unwieldy as they grow, reducing side effects when making changes and improving collaboration in codebases.

❌ Bad Example: Mega-Component Handling Data Fetching, Rendering, and State

const UserDashboard = () => {
  // Responsibility 1: Data fetching
  const [users, setUsers] = useState([]);
  useEffect(() => {
    fetch("/api/users")
      .then(res => res.json())
      .then(setUsers);
  }, []);

  // Responsibility 2: Search logic
  const [searchTerm, setSearchTerm] = useState("");
  const filteredUsers = users.filter(user => 
    user.name.toLowerCase().includes(searchTerm.toLowerCase())
  );

  // Responsibility 3: Rendering UI
  return (
    <div>
      <input 
        type="text" 
        placeholder="Search users..." 
        onChange={(e) => setSearchTerm(e.target.value)}
      />
      <ul>
        {filteredUsers.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
};

🚨 Issue:

  • The component mixes data fetching, state management, business logic (search), and rendering.
  • Changes to any one responsibility (e.g., API endpoint) risk breaking unrelated parts.
  • Reusing the search or data-fetching logic elsewhere is impossible without duplication.

✅ Better Approach: Split Responsibilities into Hooks and Components

// Responsibility 1: Data fetching (custom hook)
const useUsers = () => {
  const [users, setUsers] = useState([]);
  useEffect(() => {
    fetch("/api/users").then(res => res.json()).then(setUsers);
  }, []);
  return users;
};

// Responsibility 2: Search logic (reusable utility)
const useSearch = (data, searchKey) => {
  const [searchTerm, setSearchTerm] = useState("");
  const filteredData = data.filter(item => 
    item[searchKey].toLowerCase().includes(searchTerm.toLowerCase())
  );
  return { filteredData, searchTerm, setSearchTerm };
};

// Responsibility 3: Presentational UI only
const UserList = ({ users }) => (
  <ul>
    {users.map(user => <li key={user.id}>{user.name}</li>)}
  </ul>
);

// Responsibility 4: Search input (reusable component)
const SearchInput = ({ value, onChange }) => (
  <input 
    type="text" 
    placeholder="Search..." 
    value={value} 
    onChange={onChange} 
  />
);

// Final composition
const UserDashboard = () => {
  const users = useUsers();
  const { filteredData, searchTerm, setSearchTerm } = useSearch(users, "name");
  
  return (
    <div>
      <SearchInput value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
      <UserList users={filteredData} />
    </div>
  );
};

✅ Why This Works:

  • Separation of concerns: Each hook/component handles one task:
    • useUsers: Data fetching
    • useSearch: Search filtering
    • UserList/SearchInput: Pure UI rendering
  • Reusability: SearchInput and useSearch can be used in other components.
  • Testability: Logic and UI can be tested independently.

🛠 Advanced SRP: Atomic Design for Scalability

Break UI into atomic units (atoms/molecules):

// Atoms (smallest units: buttons, inputs)
const Button = ({ children, onClick }) => (
  <button onClick={onClick}>{children}</button>
);

// Molecules (groups of atoms: search bar)
const SearchBar = ({ onSearch }) => {
  const [term, setTerm] = useState("");
  return (
    <div>
      <input 
        value={term} 
        onChange={(e) => setTerm(e.target.value)} 
      />
      <Button onClick={() => onSearch(term)}>Search</Button>
    </div>
  );
};

// Organisms (complex components: user dashboard)
const UserDashboard = () => { /* Compose molecules here */ };

Key Takeaway: Design React components and hooks to do one thing exceptionally well. Split logic (data/state), utilities (search/filters), and UI (presentation) into separate units. This ensures maintainability, reusability, and aligns with SRP.


2. Open/Closed Principle (OCP) → "Extensible Design"

Software entities (e.g., components, hooks) should be open for extension but closed for modification. In React, this means designing components that allow new behaviors or variations to be added without altering their source code.

🚀 Why It Matters in React

Components that follow OCP reduce technical debt by enabling feature additions through composition or configuration instead of constant tweaks to existing code. This minimizes regression risks, improves scalability, and keeps core components stable.

❌ Bad Example: Hardcoded Button Variants

const ThemeButton = ({ children, variant }) => {
  // Closed to extension: Adding a new variant requires editing this component
  const styles = {
    primary: "bg-blue-500 text-white",
    secondary: "bg-gray-500 text-white",
    // Need to add a "tertiary" variant? Must modify THIS file!
  };

  return (
    <button className={`p-2 rounded ${styles[variant]}`}>
      {children}
    </button>
  );
};

// Usage
<ThemeButton variant="primary">Save</ThemeButton>

🚨 Issue:

  • To add a new button style (e.g., tertiary), you must modify the ThemeButton source code.
  • The component is not open for extension—every new use case risks breaking existing implementations.
  • Results in bloated components with endless if/switch statements over time.

✅ Better Approach: Extensible via Props and Composition

// Base component closed to modification
const BaseButton = ({ children, className, ...props }) => (
  <button 
    {...props} 
    className={`p-2 rounded ${className || ""}`}
  >
    {children}
  </button>
);

// Extend via props (CSS-in-JS, Tailwind, etc.) WITHOUT changing BaseButton
const PrimaryButton = (props) => (
  <BaseButton 
    {...props} 
    className="bg-blue-500 text-white" 
  />
);

const SecondaryButton = (props) => (
  <BaseButton 
    {...props} 
    className="bg-gray-500 text-white" 
  />
);

// New "tertiary" variant added WITHOUT touching BaseButton
const TertiaryButton = (props) => (
  <BaseButton 
    {...props} 
    className="bg-purple-500 text-white" 
  />
);

// Usage
<PrimaryButton>Save</PrimaryButton>

Why This Works:

  • Closed for modification: BaseButton’s core structure remains untouched.
  • Open for extension: New variants (TertiaryButton) are created by composing BaseButton with new props.
  • Consumers can even extend it further with their own styles:
const CustomButton = () => (
  <BaseButton className="my-custom-class">Hi</BaseButton>
);

🛠 Advanced OCP: Component Configuration with Context

For complex extensibility, use React Context to provide default behaviors that can be overridden:

// 1. Define a configurable context
const ButtonConfigContext = createContext({
  defaultStyle: "bg-gray-100 text-black",
});

// 2. Base component consumes context
const SmartButton = ({ className, ...props }) => {
  const { defaultStyle } = useContext(ButtonConfigContext);
  return (
    <BaseButton 
      {...props} 
      className={`${defaultStyle} ${className}`} 
    />
  );
};

// 3. Extend by wrapping with a new context provider
const BrandedButtonGroup = ({ children }) => (
  <ButtonConfigContext.Provider 
    value={{ defaultStyle: "bg-brand-blue text-white" }}
  >
    {children}
  </ButtonConfigContext.Provider>
);

// Usage: Branded buttons inherit new styles without component edits
<BrandedButtonGroup>
  <SmartButton>Click Me</SmartButton> {/* Uses brand-blue styles */}
</BrandedButtonGroup>

Key Takeaway: Design React components to be configurable (via props, context, or CSS) and composable (via wrapper components or hooks). This aligns with OCP by letting consumers extend functionality without modifying your source code, fostering a robust and adaptable UI ecosystem.


3. Liskov Substitution Principle (LSP) → "Predictable Components"

Subtypes (e.g., child components) must be substitutable for their base types (e.g., parent components) without altering the correctness of the program. In React, this means components inheriting or extending behavior should not break the expectations of their parent’s props, state, or UI/UX.

🚀 Why It Matters in React

React’s composability relies on components behaving predictably when reused or extended. Violating LSP leads to fragile components, unexpected bugs, or inconsistent behavior when substituting child components for parents.

❌ Bad Example: Overriding Behavior Incorrectly in a Child Component

// Parent Component
const BaseButton = ({ onClick, children, className }) => (
  <button onClick={onClick} className={`base-btn ${className}`}>
    {children}
  </button>
);

// Child Component violating LSP
const SubmitButton = ({ onClick, children }) => {
  const handleClick = () => {
    // Forgets to call onClick from props!
    console.log("Submit logic here");
  };

  return (
    <BaseButton onClick={handleClick} className="submit-btn">
      {children}
    </BaseButton>
  );
};

🚨 Issue: SubmitButton replaces BaseButton but fails to invoke the onClick prop. Any code relying on BaseButton’s click-handling logic (e.g., analytics tracking) breaks when substituted with SubmitButton.

✅ Better Approach: Preserving Parent Contracts via Composition

// Parent Component (same as before)
const BaseButton = ({ onClick, children, className }) => (
  <button onClick={onClick} className={`base-btn ${className}`}>
    {children}
  </button>
);

// Child Component using composition
const SubmitButton = ({ onClick, children }) => {
  const handleClick = (e) => {
    console.log("Submit logic here");
    onClick?.(e); // Preserves the parent’s onClick contract
  };

  return (
    <BaseButton onClick={handleClick} className="submit-btn">
      {children}
    </BaseButton>
  );
};

✅ Why This Works:

  • SubmitButton composes BaseButton instead of reinventing it.
  • It extends the parent’s behavior by adding submit logic without breaking the original onClick prop.
  • Consumers can safely substitute BaseButton with SubmitButton, as the latter honors the parent’s API contract.

4. Interface Segregation Principle (ISP) → "Focused interfaces"

Clients (e.g., components, hooks) should not be forced to depend on interfaces (e.g., props, APIs) they do not use. In React, this means designing components with focused prop interfaces to avoid unnecessary dependencies and complexity.

🚀 Why It Matters in React

Components with bloated prop interfaces become hard to maintain, reuse, or test. ISP ensures components only require relevant props, reducing coupling and preventing "prop-drilling hell." It also minimizes unnecessary re-renders caused by extraneous prop changes.

❌ Bad Example: Monolithic Component with Unused Props

// A "kitchen sink" component forcing consumers to handle unrelated props
const UserProfile = ({ user, onEdit, showEditButton, showAvatar, theme }) => {
  return (
    <div className={theme}>
      {showAvatar && <img src={user.avatar} />}
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      {showEditButton && <button onClick={onEdit}>Edit</button>}
    </div>
  );
};

// Usage: Consumers must pass ALL props, even if unused
<UserProfile 
  user={user} 
  onEdit={handleEdit} 
  showEditButton={true} 
  showAvatar={false} // Ignored in this case
  theme="dark-mode"
/>

🚨 Issue:

  • The component mixes UI logic (e.g., theme), conditional rendering (e.g., showAvatar), and data (user).
  • Consumers must pass all props, even irrelevant ones like showAvatar when not needed.
  • Adding new features (e.g., a onDelete prop) forces existing consumers to update, risking bloat.

✅ Better Approach: Splitting into Focused Components

// Break down into smaller, single-responsibility components
const UserProfile = ({ user, children }) => (
  <div>
    <h2>{user.name}</h2>
    <p>{user.email}</p>
    {children}
  </div>
);

const Avatar = ({ user }) => <img src={user.avatar} />;
const EditButton = ({ onEdit }) => <button onClick={onEdit}>Edit</button>;

// Usage: Compose only what’s needed
<UserProfile user={user}>
  <EditButton onEdit={handleEdit} />
</UserProfile>

✅ Why This Works:

  • Focused components: UserProfile handles core data, while Avatar and EditButton manage specific features.
  • No unused props: Consumers compose only the components they need (e.g., omit Avatar if unused).
  • Easier maintenance: Changes to Avatar or EditButton don’t affect unrelated components.

🛠 Advanced ISP: Custom Hooks for Logic Segregation

For complex components, split logic into granular hooks:

// Separate hooks for distinct concerns
const useUserData = (user) => ({ name: user.name, email: user.email }); // user can be a large object, we're only interested in name and email
const useTheme = (theme) => ({ className: theme });

// Consumer picks only needed hooks
const ThemedUserProfile = ({ user }) => {
  const { name, email } = useUserData(user);
  const { className } = useTheme("dark-mode");
  return (
    <div className={className}>
      <h2>{name}</h2>
      <p>{email}</p>
    </div>
  );
};

Key Takeaway: Design React components and hooks to expose minimal, context-specific interfaces. Use composition and atomic design patterns to avoid forcing unnecessary dependencies. This keeps codebases scalable, performant, and aligned with ISP.


5. Dependency Inversion Principle (DIP) → "Abstraction Over Details"

High-level modules (e.g., components) should not depend on low-level modules (e.g., APIs, services). Both should depend on abstractions (e.g., interfaces, props). In React, this means components should rely on contracts (like props or context APIs) rather than concrete implementations, making them decoupled and reusable.

🚀 Why It Matters in React

Tight coupling to specific services, libraries, or data sources creates brittle components that are hard to test, maintain, or reuse. DIP enables inversion of control (IoC), letting you swap implementations (e.g., mock APIs, alternate UI libraries) without rewriting dependent components.

❌ Bad Example: Tightly Coupled API Dependency

// Low-level module (concrete API service)
const UserService = {
  getUsers: () => axios.get("/api/users"),
};

// High-level component directly depends on UserService
const UserList = () => {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    UserService.getUsers().then(res => setUsers(res.data));
  }, []);

  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
};

🚨 Issue:

  • UserList is rigidly tied to UserService and Axios.
  • Changing the API endpoint or switching to GraphQL/Fetch requires modifying the component.
  • Unit testing requires mocking Axios globally, leading to fragile tests.

✅ Better Approach: Depend on Abstractions via Props/Context

// 1. Define an abstraction (interface) for the service
type UserFetcher = () => Promise<User[]>;

// 2. High-level component depends on the abstraction via props
const UserList = ({ fetchUsers }: { fetchUsers: UserFetcher }) => {
  const [users, setUsers] = useState<User[]>([]);

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

  return ( /* Render users */ );
};

// 3. Inject low-level implementation via composition
const App = () => {
  // Concrete implementation (could be swapped for a mock, GraphQL, etc.)
  const fetchUsers = () => axios.get("/api/users").then(res => res.data);

  return <UserList fetchUsers={fetchUsers} />;
};

✅ Why This Works:

  • Inverted dependency: UserList depends on the UserFetcher interface, not on Axios or /api/users.
  • Testability: Pass a mock fetchUsers in tests:
<UserList fetchUsers={() => Promise.resolve([{ id: 1, name: "Test User" }])} />

Final Thoughts: SOLID React, Solid Apps 🧩

Applying SOLID principles in React isn’t about rigid rules but fostering a mindset of clean, adaptable, and collaborative code. When combined, these principles:

  1. Reduce Complexity
    • Break monolithic components into focused, testable units (SRP, ISP).
  2. Future-Proof Your Code
    • Enable extension without rewriting (OCP, LSP) and minimize ripple effects from changes.
  3. Boost Team Scalability
    • Decoupled components and abstractions (DIP) let teams work independently without stepping on toes.
  4. Unlock Reusability
    • Composable, context-agnostic components (DIP, LSP) become shared assets across projects.

While strict adherence isn’t always practical (e.g., over-abstracting simple UIs), prioritizing SOLID guides you toward resilient architectures that survive shifting requirements, scaling teams, and tech stack evolutions. Aim for components that feel like LEGO blocks—simple alone, powerful together—and your React apps will evolve gracefully, even as requirements shift.

💡 Remember: SOLID is a means to an end—shipping quality software efficiently. Use it to empower your React apps, not to over-engineer them. Start small, iterate, and let these principles grow naturally with your codebase!

Which principle resonates most with your React projects? Let me know! 🚀

Get the latest articles, in your inbox

Every couple of weeks, I share the best content from my blog. It's short, sweet, and practical. I'd love you to join. You may opt out at any time.