DumbPhobia#008 SOLID Principles

SOLID is not just an OOP concept for backend Java developers. Every principle maps directly to how you design React components, custom hooks, and frontend architecture — and mastering them is what makes code maintainable at scale.

What is SOLID?

SOLID is an acronym for five design principles that make software easier to understand, extend, and maintain. Coined by Robert C. Martin, they were originally described in object-oriented terms — but their intent applies universally to any modular system, including modern frontend development.

LetterPrincipleCore idea
SSingle Responsibility PrincipleA module should have one reason to change
OOpen/Closed PrincipleOpen for extension, closed for modification
LLiskov Substitution PrincipleSubtypes must be substitutable for their base types
IInterface Segregation PrincipleNo module should depend on interfaces it doesn't use
DDependency Inversion PrincipleDepend on abstractions, not concretions

Each principle solves a specific class of design problem. Together, they push you toward components and modules that are small, focused, composable, and independently testable.


S — Single Responsibility Principle

The principle

A module, class, or component should have one — and only one — reason to change.

If a component does too many things, any change to one concern risks breaking another. The goal is not to limit size but to limit reasons to change.

The violation

// ❌ This component does everything:
// - Fetches data
// - Formats data
// - Handles errors
// - Manages pagination
// - Renders the UI
// - Handles user interaction
// → Multiple reasons to change

function UserList() {
  const [users, setUsers]     = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError]     = useState(null);
  const [page, setPage]       = useState(1);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users?page=${page}`)
      .then(res => {
        if (!res.ok) throw new Error('Failed to fetch');
        return res.json();
      })
      .then(data => {
        setUsers(data.users.map(u => ({
          ...u,
          fullName: `${u.firstName} ${u.lastName}`,
          joinedAt: new Date(u.createdAt).toLocaleDateString(),
        })));
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [page]);

  if (loading) return <div>Loading...</div>;
  if (error)   return <div>Error: {error}</div>;

  return (
    <div>
      {users.map(user => (
        <div key={user.id}>
          <img src={user.avatar} alt={user.fullName} />
          <h3>{user.fullName}</h3>
          <p>Joined: {user.joinedAt}</p>
        </div>
      ))}
      <button onClick={() => setPage(p => p - 1)} disabled={page === 1}>Prev</button>
      <button onClick={() => setPage(p => p + 1)}>Next</button>
    </div>
  );
}

This component has at least five reasons to change: the API changes, the formatting logic changes, the error handling changes, the pagination behavior changes, or the UI design changes.

The fix

Split by responsibility:

// 1. Data fetching — one reason to change: API changes
function useUsers(page: number) {
  return useQuery({
    queryKey: ['users', page],
    queryFn: () => fetch(`/api/users?page=${page}`).then(r => r.json()),
  });
}

// 2. Data formatting — one reason to change: display format changes
function formatUser(user: RawUser): DisplayUser {
  return {
    ...user,
    fullName: `${user.firstName} ${user.lastName}`,
    joinedAt: new Date(user.createdAt).toLocaleDateString(),
  };
}

// 3. Pagination logic — one reason to change: pagination behavior changes
function usePagination(initialPage = 1) {
  const [page, setPage] = useState(initialPage);
  return {
    page,
    nextPage: () => setPage(p => p + 1),
    prevPage: () => setPage(p => p - 1),
    canGoBack: page > 1,
  };
}

// 4. Single user display — one reason to change: user card UI changes
function UserCard({ user }: { user: DisplayUser }) {
  return (
    <div className="user-card">
      <img src={user.avatar} alt={user.fullName} />
      <h3>{user.fullName}</h3>
      <p>Joined: {user.joinedAt}</p>
    </div>
  );
}

// 5. Orchestration — one reason to change: how the pieces connect
function UserList() {
  const { page, nextPage, prevPage, canGoBack } = usePagination();
  const { data, isLoading, isError } = useUsers(page);

  if (isLoading) return <LoadingState />;
  if (isError)   return <ErrorState />;

  const users = data.users.map(formatUser);

  return (
    <div>
      {users.map(user => <UserCard key={user.id} user={user} />)}
      <Pagination onNext={nextPage} onPrev={prevPage} canGoBack={canGoBack} />
    </div>
  );
}

Now each piece has exactly one reason to change — and can be tested in complete isolation.

Use cases

  • Separating data fetching logic into custom hooks
  • Extracting formatting and transformation utilities
  • Keeping UI components purely presentational
  • Isolating side effects from rendering logic

Pros

  • Each unit is independently testable
  • Changes are isolated — modifying one concern doesn't risk breaking others
  • Code is easier to navigate — you know exactly where to look for each concern

Cons

  • More files and more indirection
  • Can feel like over-engineering for truly simple components
  • Requires discipline to know where to draw the boundary

O — Open/Closed Principle

The principle

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

You should be able to add new behavior without changing existing, working code. In frontend terms: extend through composition and configuration, not by editing internals.

The violation

// ❌ Every new button variant requires modifying this component
function Button({ variant, children }) {
  if (variant === 'primary') {
    return <button className="bg-blue-500 text-white px-4 py-2">{children}</button>;
  }
  if (variant === 'secondary') {
    return <button className="bg-gray-200 text-gray-800 px-4 py-2">{children}</button>;
  }
  if (variant === 'danger') {
    return <button className="bg-red-500 text-white px-4 py-2">{children}</button>;
  }
  // Adding "success", "ghost", "outline" all require editing this file
  return <button>{children}</button>;
}

Every new variant requires opening this file, adding another if branch, and risking a regression.

The fix

// ✅ New variants extend the config — the component itself never changes

const BUTTON_VARIANTS = {
  primary:   'bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600',
  secondary: 'bg-gray-200 text-gray-800 px-4 py-2 rounded hover:bg-gray-300',
  danger:    'bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600',
  success:   'bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600',
  ghost:     'bg-transparent border border-gray-300 px-4 py-2 rounded hover:bg-gray-50',
} as const;

type ButtonVariant = keyof typeof BUTTON_VARIANTS;

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
}

// This component never needs to change when new variants are added
function Button({ variant = 'primary', className = '', children, ...props }: ButtonProps) {
  return (
    <button
      className={`${BUTTON_VARIANTS[variant]} ${className}`}
      {...props}
    >
      {children}
    </button>
  );
}

// Adding a new variant = adding one line to BUTTON_VARIANTS
// No component code changes needed

OCP through composition

OCP is also expressed through the component composition pattern — instead of building every variation into a component, accept children and render props:

// ❌ Closed: every layout variation requires editing the component
function Card({ showHeader, showFooter, title, content, actions }) {
  return (
    <div className="card">
      {showHeader && <div className="card-header">{title}</div>}
      <div className="card-body">{content}</div>
      {showFooter && <div className="card-footer">{actions}</div>}
    </div>
  );
}

// ✅ Open: any content can be composed in without touching the Card component
function Card({ header, children, footer }) {
  return (
    <div className="card">
      {header && <div className="card-header">{header}</div>}
      <div className="card-body">{children}</div>
      {footer && <div className="card-footer">{footer}</div>}
    </div>
  );
}

// Usage — fully extensible, Card never changes
<Card
  header={<h2>User Profile</h2>}
  footer={<Button>Save Changes</Button>}
>
  <ProfileForm user={user} />
</Card>

Use cases

  • Design system components (buttons, inputs, cards, modals)
  • Layout wrappers that accept arbitrary content via children
  • Feature flags as configuration, not conditionals
  • Plugin or middleware systems

Pros

  • Existing, tested code stays untouched when extending
  • New variants/behaviors don't risk breaking existing ones
  • Encourages thinking in configurations and compositions

Cons

  • Requires anticipating extension points up front
  • Over-abstraction risk — not everything needs to be open
  • Configuration-driven components can become hard to trace

L — Liskov Substitution Principle

The principle

Objects of a subtype should be substitutable for objects of their base type without altering the correctness of the program.

In React terms: if a component accepts a prop type or extends another component, it must honor the full contract of that base type. A consumer should be able to swap in a substituted variant without changing any of their own code.

The violation

// Base component — a standard anchor element
function Link({ href, children, onClick }) {
  return <a href={href} onClick={onClick}>{children}</a>;
}

// ❌ Subtype that silently breaks the contract
// This looks like a Link but doesn't navigate — it violates the substitution principle
function ExternalLink({ href, children }) {
  // Silently ignores onClick — breaks the base contract
  // Always opens in new tab — changes expected navigation behavior
  return (
    <a href={href} target="_blank">
      {children}
    </a>
  );
}

// Consumer code breaks when ExternalLink is substituted for Link
function Nav() {
  return (
    <Link href="/about" onClick={() => trackNavigation('about')}>
      About
    </Link>
    // Swapping to ExternalLink silently drops the onClick tracking
  );
}

The fix

// ✅ ExternalLink honors the full Link contract, then extends it
interface LinkProps {
  href: string;
  children: React.ReactNode;
  onClick?: (e: React.MouseEvent) => void;
}

function Link({ href, children, onClick }: LinkProps) {
  return <a href={href} onClick={onClick}>{children}</a>;
}

// ExternalLink extends Link without breaking the contract
function ExternalLink({ href, children, onClick }: LinkProps) {
  const handleClick = (e: React.MouseEvent) => {
    onClick?.(e);         // honor the base contract — call onClick if provided
  };

  return (
    <a
      href={href}
      onClick={handleClick}
      target="_blank"
      rel="noopener noreferrer"
    >
      {children}
      <ExternalIcon aria-label="opens in new tab" />
    </a>
  );
}

// Now ExternalLink is a true substitute for Link — no behavior is silently dropped

LSP and HTML element contracts

A common LSP violation in React is wrapping an HTML element but not spreading its native props — breaking the implicit contract of that element:

// ❌ Violates LSP — consumers expect standard button behavior
// but cannot use disabled, type, aria attributes, etc.
function IconButton({ icon, onClick }) {
  return (
    <button onClick={onClick}>
      <Icon name={icon} />
    </button>
  );
}

// ✅ Honors the full button contract via prop spreading
function IconButton({
  icon,
  children,
  ...props  // spreads all standard button attributes — disabled, type, aria-*, etc.
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { icon: string }) {
  return (
    <button {...props}>
      <Icon name={icon} />
      {children}
    </button>
  );
}

// Now all standard button behavior is preserved
<IconButton icon="trash" onClick={handleDelete} disabled={isDeleting} aria-label="Delete post" />

Use cases

  • Design system component hierarchies (Button → IconButton → LoadingButton)
  • Custom input wrappers that must preserve native input behavior
  • Any component that extends or wraps another — honor the full props contract
  • Higher-order components and render props

Pros

  • Components are truly interchangeable — swap without surprises
  • Consumers don't need to know which specific variant they're using
  • Prevents silent behavior loss when composing components

Cons

  • Requires discipline when wrapping native elements
  • Type contracts need to be explicit — easy to miss in untyped JavaScript
  • Inheritance chains can become complex if not managed carefully

I — Interface Segregation Principle

The principle

No module should be forced to depend on interfaces it does not use.

In React terms: don't pass props a component doesn't need. Fat prop interfaces create unnecessary coupling — changes to unused props can still force a component to re-render or be rewritten.

The violation

// ❌ One monolithic user object passed everywhere
interface User {
  id: string;
  firstName: string;
  lastName: string;
  email: string;
  phone: string;
  avatarUrl: string;
  bio: string;
  role: string;
  billingAddress: Address;
  paymentMethods: PaymentMethod[];
  subscriptionTier: string;
  lastLoginAt: string;
  createdAt: string;
  preferences: UserPreferences;
}

// This component only needs id and avatarUrl — but depends on the entire User type
function UserAvatar({ user }: { user: User }) {
  return <img src={user.avatarUrl} alt={user.firstName} />;
}

// This component only needs firstName, lastName, role — but depends on the entire User type
function UserNameTag({ user }: { user: User }) {
  return <div>{user.firstName} {user.lastName} — {user.role}</div>;
}

// Now any change to User (e.g., adding a new billing field) forces review
// of every component that accepts User — even those that don't use it

The fix

// ✅ Each component declares only the props it actually needs

// Segregated interfaces — each component defines its own minimal contract
interface AvatarProps {
  avatarUrl: string;
  firstName: string; // for alt text only
}

interface NameTagProps {
  firstName: string;
  lastName: string;
  role: string;
}

interface BillingCardProps {
  billingAddress: Address;
  paymentMethods: PaymentMethod[];
}

function UserAvatar({ avatarUrl, firstName }: AvatarProps) {
  return <img src={avatarUrl} alt={firstName} />;
}

function UserNameTag({ firstName, lastName, role }: NameTagProps) {
  return <div>{firstName} {lastName} — {role}</div>;
}

// Composition — parent extracts and passes only what each child needs
function UserProfile({ user }: { user: User }) {
  return (
    <div>
      <UserAvatar avatarUrl={user.avatarUrl} firstName={user.firstName} />
      <UserNameTag
        firstName={user.firstName}
        lastName={user.lastName}
        role={user.role}
      />
    </div>
  );
}

ISP and prop spreading caution

Prop spreading seems like a way to avoid ISP violations but can actually make them worse:

// ❌ Spreading the whole user object hides what the component actually needs
function UserCard({ ...user }) {
  return <div>{user.firstName}</div>; // what else is in here? Nobody knows.
}

// ✅ Be explicit — document the contract
function UserCard({ firstName, role, avatarUrl }: Pick<User, 'firstName' | 'role' | 'avatarUrl'>) {
  return (
    <div>
      <img src={avatarUrl} alt={firstName} />
      <p>{firstName} — {role}</p>
    </div>
  );
}

ISP applied to custom hooks

The same principle applies to hooks — don't return a fat object when the consumer only needs part of it:

// ❌ Fat hook return — consumers are coupled to the entire interface
function useUser() {
  return { user, updateUser, deleteUser, resetPassword, uploadAvatar, ...rest };
}

// ✅ Segregated hooks — consumers depend only on what they use
function useUserProfile() { return { user, updateUser }; }
function useUserSecurity() { return { resetPassword }; }
function useUserAvatar()   { return { uploadAvatar }; }

Use cases

  • Design system components with clearly scoped prop interfaces
  • Custom hooks that return only what their caller needs
  • API response types — don't pass the full response where a subset is sufficient
  • Context — split one large context into multiple focused contexts

Pros

  • Components are easier to test — minimal, predictable inputs
  • Re-renders are minimized — components only update when their specific props change
  • Interfaces are self-documenting — the props list tells you exactly what the component needs
  • Changes to unrelated fields don't propagate unnecessarily

Cons

  • More prop-passing boilerplate at composition layer
  • Requires designing interfaces up front rather than just passing objects
  • Can lead to prop drilling if not managed with context or state management

D — Dependency Inversion Principle

The principle

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

In React terms: components should depend on interfaces (prop contracts, hook signatures), not on concrete implementations (specific API clients, specific state libraries, specific services). This makes components portable, testable, and swappable.

The violation

// ❌ Component is tightly coupled to a specific implementation
// Fetching, authentication, and analytics are all hardcoded

import { supabase } from '@/lib/supabase';
import { mixpanel } from '@/lib/mixpanel';

function LoginForm() {
  const [email, setEmail]       = useState('');
  const [password, setPassword] = useState('');

  async function handleSubmit(e) {
    e.preventDefault();

    // Hardcoded to Supabase — cannot test without a real Supabase instance
    const { error } = await supabase.auth.signInWithPassword({ email, password });

    if (error) {
      console.error(error);
      return;
    }

    // Hardcoded to Mixpanel — cannot test without Mixpanel
    mixpanel.track('user_logged_in', { email });

    navigate('/dashboard');
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={e => setEmail(e.target.value)} type="email" />
      <input value={password} onChange={e => setPassword(e.target.value)} type="password" />
      <button type="submit">Login</button>
    </form>
  );
}

To test this component, you need a real Supabase connection and a real Mixpanel instance. Swapping Supabase for Firebase means editing the component directly.

The fix

// ✅ Component depends on abstractions — not on concrete services

// Define the abstraction (the interface)
interface AuthService {
  login: (email: string, password: string) => Promise<{ error?: string }>;
}

interface AnalyticsService {
  track: (event: string, properties?: Record<string, unknown>) => void;
}

// Component depends on the abstractions — not on Supabase or Mixpanel
interface LoginFormProps {
  authService: AuthService;
  analyticsService: AnalyticsService;
  onSuccess: () => void;
}

function LoginForm({ authService, analyticsService, onSuccess }: LoginFormProps) {
  const [email, setEmail]       = useState('');
  const [password, setPassword] = useState('');
  const [error, setError]       = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    const result = await authService.login(email, password);

    if (result.error) {
      setError(result.error);
      return;
    }

    analyticsService.track('user_logged_in', { email });
    onSuccess();
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <p className="text-red-500">{error}</p>}
      <input value={email} onChange={e => setEmail(e.target.value)} type="email" />
      <input value={password} onChange={e => setPassword(e.target.value)} type="password" />
      <button type="submit">Login</button>
    </form>
  );
}

// Concrete implementations — depend on the abstraction, not the other way around
const supabaseAuthService: AuthService = {
  login: async (email, password) => {
    const { error } = await supabase.auth.signInWithPassword({ email, password });
    return { error: error?.message };
  },
};

const mixpanelAnalyticsService: AnalyticsService = {
  track: (event, properties) => mixpanel.track(event, properties),
};

// In production
<LoginForm
  authService={supabaseAuthService}
  analyticsService={mixpanelAnalyticsService}
  onSuccess={() => navigate('/dashboard')}
/>

// In tests — inject a mock — no real services needed
const mockAuth: AuthService = {
  login: jest.fn().mockResolvedValue({}),
};
const mockAnalytics: AnalyticsService = {
  track: jest.fn(),
};

render(<LoginForm authService={mockAuth} analyticsService={mockAnalytics} onSuccess={onSuccess} />);

DIP via custom hooks

Abstract the implementation behind a hook interface — swap the implementation without touching the component:

// Abstraction — the hook contract
interface UseAuthReturn {
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  user: User | null;
  isLoading: boolean;
}

// Concrete implementation — Supabase
function useSupabaseAuth(): UseAuthReturn { /* ... */ }

// Concrete implementation — Firebase
function useFirebaseAuth(): UseAuthReturn { /* ... */ }

// Component depends only on the hook interface
function Header() {
  const { user, logout } = useAuth(); // swap implementation without touching Header
  return (
    <header>
      {user ? <button onClick={logout}>Logout</button> : <Link to="/login">Login</Link>}
    </header>
  );
}

DIP via Context

Context is one of the cleanest ways to apply DIP in React — inject the implementation at the top of the tree:

// Define the abstraction
const AuthContext = createContext<AuthService | null>(null);

function useAuthService() {
  const service = useContext(AuthContext);
  if (!service) throw new Error('AuthService not provided');
  return service;
}

// Inject the concrete implementation at the app root — swap here, nowhere else
function App() {
  return (
    <AuthContext.Provider value={supabaseAuthService}>
      <Router>
        <Routes>...</Routes>
      </Router>
    </AuthContext.Provider>
  );
}

// In tests — inject a mock at the context level
render(
  <AuthContext.Provider value={mockAuthService}>
    <LoginForm />
  </AuthContext.Provider>
);

Use cases

  • Abstracting third-party services (auth, analytics, payments, logging)
  • Making components testable without real API connections
  • Building design systems that work across multiple data-fetching strategies
  • Feature flags as injected services
  • Swappable state management implementations

Pros

  • Components become fully testable in isolation — inject mocks
  • Implementations are swappable — replace Supabase with Firebase without touching UI code
  • Business logic is decoupled from infrastructure
  • Teams can develop UI and backend in parallel against agreed interfaces

Cons

  • More upfront abstraction design required
  • Prop injection can become verbose — Context or a DI container helps
  • Can feel over-engineered for small applications where the implementation will never change

All Five Principles Together

A well-designed component naturally satisfies all five:

// A component that follows all SOLID principles:

// S — Single Responsibility
//     Only renders a user card. Data fetching is in useUser(). Formatting is in formatUser().

// O — Open/Closed
//     New action buttons extend via the `actions` prop — the card itself never changes.

// L — Liskov Substitution
//     Spreads all div props — fully substitutable for any standard div consumer.

// I — Interface Segregation
//     Accepts only the fields it actually uses — not the full User object.

// D — Dependency Inversion
//     Receives onFollow as an injected callback — not coupled to any specific follow service.

interface UserCardProps extends React.HTMLAttributes<HTMLDivElement> {
  firstName: string;   // I — only what's needed
  lastName: string;
  role: string;
  avatarUrl: string;
  actions?: React.ReactNode;         // O — open for extension
  onFollow?: () => void;             // D — injected behavior, not hardcoded
}

function UserCard({
  firstName,
  lastName,
  role,
  avatarUrl,
  actions,
  onFollow,
  ...divProps    // L — honors the full div contract
}: UserCardProps) {
  return (
    <div {...divProps}>
      <img src={avatarUrl} alt={`${firstName} ${lastName}`} />
      <h3>{firstName} {lastName}</h3>
      <p>{role}</p>
      {onFollow && <button onClick={onFollow}>Follow</button>}
      {actions && <div className="actions">{actions}</div>}
    </div>
  );
}

SOLID Violations Cheat Sheet

SymptomLikely violation
Component does fetching, formatting, and renderingS — Single Responsibility
Adding a feature requires editing an existing componentO — Open/Closed
Swapping a component variant breaks behavior silentlyL — Liskov Substitution
Component receives a huge object but uses two fieldsI — Interface Segregation
Component imports supabase, axios, or mixpanel directlyD — Dependency Inversion
Can't test a component without a real API connectionD — Dependency Inversion
Changing one prop type forces changes across many componentsI — Interface Segregation
Every new variant adds an if branch inside the componentO — Open/Closed

Quick Reference Card

S — Single Responsibility
  ├─ Rule:    One reason to change per module
  ├─ Pattern: Separate hooks (fetching), utils (formatting), components (rendering)
  ├─ Signal:  "This component also handles X" → extract X
  └─ Test:    Can you test each concern in isolation?

O — Open/Closed
  ├─ Rule:    Extend via configuration and composition, not modification
  ├─ Pattern: Variant maps, children props, render props, slots
  ├─ Signal:  "I need to add an if-branch for a new variant" → externalize the config
  └─ Test:    Can you add a new variant without touching the component file?

L — Liskov Substitution
  ├─ Rule:    Subtypes must honor the full contract of their base type
  ├─ Pattern: Spread native element props (...props), forward refs, honor all callbacks
  ├─ Signal:  "This variant silently ignores onClick / disabled / aria-*" → violation
  └─ Test:    Can you swap this variant in without any consumer code changing?

I — Interface Segregation
  ├─ Rule:    Components depend only on props they actually use
  ├─ Pattern: Destructure specific fields, use Pick<T>, split fat hooks
  ├─ Signal:  "This component takes the whole User object but uses two fields" → trim it
  └─ Test:    Does the prop interface read like a description of what the component does?

D — Dependency Inversion
  ├─ Rule:    Depend on abstractions (interfaces, hooks) not concretions (Supabase, Axios)
  ├─ Pattern: Inject services via props or Context, define hook interfaces
  ├─ Signal:  "I have to mock Supabase to test this button" → invert the dependency
  └─ Test:    Can you test this component with pure mock objects and no network?

SOLID principles are not rules to follow mechanically — they're lenses for identifying design problems. When a codebase becomes painful to change, one of these five principles is almost always being violated somewhere. Learn to recognize the symptoms, and you'll know exactly where to start refactoring.