Toolbox
Shelf

Pages

  • Home
  • Shelf
  • Toolbox

Extras

  • Resume

Crafted with and few cups of coffee.

Designed in Figma • Built with Next.js & Tailwind • Hosted on Vercel

© 2026 Gentle Joseph | All rights reserved.

September 15th, 2024•11 min read

SOLID Principles in React: Building Maintainable Components

SOLID Principles in React: Building Maintainable Components

When building React applications, especially as they grow in complexity, maintaining clean, scalable code becomes crucial. The SOLID principles, originally designed for object-oriented programming, can be brilliantly adapted to React development to create more maintainable and robust component architectures.

💡 Visual Learners Alert! Check out this awesome article that explains SOLID principles with practical picture examples by Ugonna Thelma. It provides excellent visual representations that complement the React-focused examples in this post!

What are SOLID Principles?

SOLID is an acronym for five design principles that make software designs more understandable, flexible, and maintainable:

  • S - Single Responsibility Principle
  • O - Open/Closed Principle
  • L - Liskov Substitution Principle
  • I - Interface Segregation Principle
  • D - Dependency Inversion Principle

Let's explore how each principle applies to React development with practical examples.

1. Single Responsibility Principle (SRP)

"A class should have a single responsibility"

In React, this translates to: Each component should have only one responsibility.

❌ Bad Example: Component with Multiple Responsibilities

// UserProfile.tsx - Doing too much!
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  // Data fetching logic
  useEffect(() => {
    fetchUser(userId)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);

  // Data formatting logic
  const formatUserData = (user) => {
    return {
      ...user,
      fullName: `${user.firstName} ${user.lastName}`,
      joinDate: new Date(user.createdAt).toLocaleDateString(),
    };
  };

  // Validation logic
  const validateUser = (user) => {
    return user && user.email && user.firstName;
  };

  // Rendering logic
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!validateUser(user)) return <div>Invalid user data</div>;

  const formattedUser = formatUserData(user);

  return (
    <div className="user-profile">
      <h1>{formattedUser.fullName}</h1>
      <p>Email: {formattedUser.email}</p>
      <p>Joined: {formattedUser.joinDate}</p>
    </div>
  );
}

✅ Good Example: Separated Responsibilities

// Custom hook for data fetching
function useUser(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchUser(userId)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);

  return { user, loading, error };
}

// Utility functions
const userFormatter = {
  formatUserData: (user) => ({
    ...user,
    fullName: `${user.firstName} ${user.lastName}`,
    joinDate: new Date(user.createdAt).toLocaleDateString(),
  }),
};

const userValidator = {
  isValid: (user) => user && user.email && user.firstName,
};

// Pure presentation component
function UserProfile({ user }) {
  if (!userValidator.isValid(user)) {
    return <div>Invalid user data</div>;
  }

  const formattedUser = userFormatter.formatUserData(user);

  return (
    <div className="user-profile">
      <h1>{formattedUser.fullName}</h1>
      <p>Email: {formattedUser.email}</p>
      <p>Joined: {formattedUser.joinDate}</p>
    </div>
  );
}

// Container component
function UserProfileContainer({ userId }) {
  const { user, loading, error } = useUser(userId);

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

  return <UserProfile user={user} />;
}

2. Open/Closed Principle (OCP)

"Classes should be open for extension, but closed for modification"

In React, this means creating components that can be extended without modifying their source code.

✅ Good Example: Extensible Button Component

// Base Button component - closed for modification
function Button({
  children,
  variant = 'primary',
  size = 'medium',
  onClick,
  className = '',
  ...props
}) {
  const baseClasses =
    'font-medium rounded transition-colors focus:outline-none focus:ring-2';

  const variantClasses = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700',
    secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
    danger: 'bg-red-600 text-white hover:bg-red-700',
  };

  const sizeClasses = {
    small: 'px-3 py-1.5 text-sm',
    medium: 'px-4 py-2 text-base',
    large: 'px-6 py-3 text-lg',
  };

  return (
    <button
      className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
      onClick={onClick}
      {...props}
    >
      {children}
    </button>
  );
}

// Extended components - open for extension
function IconButton({ icon, children, ...props }) {
  return (
    <Button className="flex items-center gap-2" {...props}>
      {icon && <span className="text-lg">{icon}</span>}
      {children}
    </Button>
  );
}

function LoadingButton({ loading, children, ...props }) {
  return (
    <Button disabled={loading} {...props}>
      {loading ? (
        <>
          <span className="animate-spin mr-2">⟳</span>
          Loading...
        </>
      ) : (
        children
      )}
    </Button>
  );
}

// Usage - extending without modifying base component
function App() {
  return (
    <div>
      <Button variant="primary">Standard Button</Button>
      <IconButton icon="⭐" variant="secondary">
        With Icon
      </IconButton>
      <LoadingButton loading={true} variant="danger">
        Loading...
      </LoadingButton>
    </div>
  );
}

3. Liskov Substitution Principle (LSP)

"If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program"

In React, this means components should be substitutable without changing the behavior of the parent component.

✅ Good Example: Interchangeable Form Components

// Base form input interface
const FormInput = ({ value, onChange, error, ...props }) => {
  return (
    <div className="form-group">
      <input
        value={value}
        onChange={onChange}
        className={`form-input ${error ? 'error' : ''}`}
        {...props}
      />
      {error && <span className="error-message">{error}</span>}
    </div>
  );
};

// Specialized input components that can substitute the base
const EmailInput = (props) => (
  <FormInput type="email" placeholder="Enter your email" {...props} />
);

const PasswordInput = (props) => (
  <FormInput type="password" placeholder="Enter your password" {...props} />
);

const TextAreaInput = ({ value, onChange, error, ...props }) => {
  return (
    <div className="form-group">
      <textarea
        value={value}
        onChange={onChange}
        className={`form-textarea ${error ? 'error' : ''}`}
        {...props}
      />
      {error && <span className="error-message">{error}</span>}
    </div>
  );
};

// Form component that works with any input type
function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: '',
  });

  const handleChange = (field) => (e) => {
    setFormData((prev) => ({
      ...prev,
      [field]: e.target.value,
    }));
  };

  return (
    <form>
      <FormInput
        value={formData.name}
        onChange={handleChange('name')}
        placeholder="Your name"
      />
      <EmailInput value={formData.email} onChange={handleChange('email')} />
      <TextAreaInput
        value={formData.message}
        onChange={handleChange('message')}
        placeholder="Your message"
      />
    </form>
  );
}

4. Interface Segregation Principle (ISP)

"Clients should not be forced to depend on methods that they do not use"

In React, this means creating focused, specific props interfaces rather than large, monolithic ones.

❌ Bad Example: Bloated Component Interface

// Bad: Component with too many responsibilities and props
function UserCard({
  user,
  showEmail,
  showPhone,
  showAddress,
  showSocialMedia,
  showLastLogin,
  showPreferences,
  onEdit,
  onDelete,
  onShare,
  onReport,
  onBlock,
  onFollow,
  onMessage,
  // ... 20 more props
}) {
  // Complex component trying to do everything
}

✅ Good Example: Segregated Interfaces

// Basic user display
function UserCard({ user, onEdit, onDelete }) {
  return (
    <div className="user-card">
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      <div className="actions">
        <button onClick={() => onEdit(user.id)}>Edit</button>
        <button onClick={() => onDelete(user.id)}>Delete</button>
      </div>
    </div>
  );
}

// Extended user display with contact info
function UserCardWithContact({ user, onEdit, onDelete, onMessage }) {
  return (
    <div className="user-card">
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      <p>{user.phone}</p>
      <div className="actions">
        <button onClick={() => onEdit(user.id)}>Edit</button>
        <button onClick={() => onDelete(user.id)}>Delete</button>
        <button onClick={() => onMessage(user.id)}>Message</button>
      </div>
    </div>
  );
}

// Social media specific component
function UserCardSocial({ user, onFollow, onShare }) {
  return (
    <div className="user-card social">
      <h3>{user.name}</h3>
      <div className="social-stats">
        <span>Followers: {user.followers}</span>
        <span>Following: {user.following}</span>
      </div>
      <div className="actions">
        <button onClick={() => onFollow(user.id)}>Follow</button>
        <button onClick={() => onShare(user.id)}>Share</button>
      </div>
    </div>
  );
}

5. Dependency Inversion Principle (DIP)

"High-level modules should not depend on low-level modules. Both should depend on the abstraction. Abstractions should not depend on details. Details should depend on abstractions"

In React, this means depending on abstractions (interfaces, custom hooks, context) rather than concrete implementations.

✅ Good Example: Dependency Injection in React

// Abstract data service interface
const createDataService = (apiClient) => ({
  getUsers: () => apiClient.get('/users'),
  getUser: (id) => apiClient.get(`/users/${id}`),
  createUser: (user) => apiClient.post('/users', user),
  updateUser: (id, user) => apiClient.put(`/users/${id}`, user),
  deleteUser: (id) => apiClient.delete(`/users/${id}`),
});

// Different implementations
const httpDataService = createDataService({
  get: (url) => fetch(url).then((res) => res.json()),
  post: (url, data) =>
    fetch(url, { method: 'POST', body: JSON.stringify(data) }),
  put: (url, data) => fetch(url, { method: 'PUT', body: JSON.stringify(data) }),
  delete: (url) => fetch(url, { method: 'DELETE' }),
});

const mockDataService = createDataService({
  get: (url) => Promise.resolve({ data: mockUsers }),
  post: (url, data) => Promise.resolve({ data: { ...data, id: Date.now() } }),
  put: (url, data) => Promise.resolve({ data }),
  delete: (url) => Promise.resolve({ success: true }),
});

// Context for dependency injection
const DataServiceContext = createContext();

export const DataServiceProvider = ({ children, service }) => (
  <DataServiceContext.Provider value={service}>
    {children}
  </DataServiceContext.Provider>
);

// Custom hook that depends on abstraction
const useDataService = () => {
  const service = useContext(DataServiceContext);
  if (!service) {
    throw new Error('useDataService must be used within DataServiceProvider');
  }
  return service;
};

// Component that depends on abstraction, not concrete implementation
function UserList() {
  const dataService = useDataService();
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    dataService
      .getUsers()
      .then((response) => setUsers(response.data))
      .finally(() => setLoading(false));
  }, [dataService]);

  if (loading) return <div>Loading...</div>;

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

// App with different service implementations
function App() {
  const isDevelopment = process.env.NODE_ENV === 'development';
  const dataService = isDevelopment ? mockDataService : httpDataService;

  return (
    <DataServiceProvider service={dataService}>
      <UserList />
    </DataServiceProvider>
  );
}

Practical Implementation Tips

1. Use Custom Hooks for Logic Separation (SRP)

// Custom hook for business logic
function useUserManagement() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);

  const addUser = async (userData) => {
    setLoading(true);
    try {
      const newUser = await userService.createUser(userData);
      setUsers((prev) => [...prev, newUser]);
    } catch (error) {
      console.error('Failed to add user:', error);
    } finally {
      setLoading(false);
    }
  };

  return { users, loading, addUser };
}

2. Create Composable Components (OCP)

// Composable modal system
function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>
  );
}

function ModalHeader({ children }) {
  return <div className="modal-header">{children}</div>;
}

function ModalBody({ children }) {
  return <div className="modal-body">{children}</div>;
}

function ModalFooter({ children }) {
  return <div className="modal-footer">{children}</div>;
}

// Usage
<Modal isOpen={showModal} onClose={() => setShowModal(false)}>
  <ModalHeader>
    <h2>Confirm Action</h2>
  </ModalHeader>
  <ModalBody>
    <p>Are you sure you want to delete this item?</p>
  </ModalBody>
  <ModalFooter>
    <button onClick={handleConfirm}>Confirm</button>
    <button onClick={() => setShowModal(false)}>Cancel</button>
  </ModalFooter>
</Modal>;

3. Create Interchangeable Components (LSP)

// Base notification component
function Notification({ message, type = 'info', onClose }) {
  const baseClasses = 'p-4 rounded shadow-lg flex justify-between items-center';
  const typeClasses = {
    info: 'bg-blue-100 text-blue-800 border-l-4 border-blue-500',
    success: 'bg-green-100 text-green-800 border-l-4 border-green-500',
    warning: 'bg-yellow-100 text-yellow-800 border-l-4 border-yellow-500',
    error: 'bg-red-100 text-red-800 border-l-4 border-red-500',
  };

  return (
    <div className={`${baseClasses} ${typeClasses[type]}`}>
      <span>{message}</span>
      <button onClick={onClose} className="ml-4 text-lg font-bold">
        ×
      </button>
    </div>
  );
}

// Specialized notification components that can substitute the base
function ToastNotification(props) {
  return <Notification {...props} className="fixed top-4 right-4 z-50" />;
}

function BannerNotification(props) {
  return <Notification {...props} className="w-full rounded-none" />;
}

function InlineNotification(props) {
  return <Notification {...props} className="mb-4" />;
}

// All can be used interchangeably
function App() {
  const [notifications, setNotifications] = useState([]);

  const addNotification = (message, type) => {
    const id = Date.now();
    setNotifications((prev) => [...prev, { id, message, type }]);
  };

  const removeNotification = (id) => {
    setNotifications((prev) => prev.filter((n) => n.id !== id));
  };

  return (
    <div>
      {/* All notification types work the same way */}
      {notifications.map((notification) => (
        <ToastNotification
          key={notification.id}
          message={notification.message}
          type={notification.type}
          onClose={() => removeNotification(notification.id)}
        />
      ))}
    </div>
  );
}

4. Design Focused Component Interfaces (ISP)

// Bad: One large interface with many optional props
function UserCard({
  user,
  showEmail,
  showPhone,
  showAddress,
  showSocialMedia,
  showLastLogin,
  showPreferences,
  onEdit,
  onDelete,
  onShare,
  onReport,
  onBlock,
  onFollow,
  onMessage,
  // ... many more props
}) {
  // Component becomes complex and hard to maintain
}

// Good: Separate focused interfaces
function UserBasicInfo({ user, onEdit }) {
  return (
    <div className="user-basic-info">
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      <button onClick={() => onEdit(user.id)}>Edit</button>
    </div>
  );
}

function UserContactInfo({ user, onMessage }) {
  return (
    <div className="user-contact-info">
      <p>Phone: {user.phone}</p>
      <p>Address: {user.address}</p>
      <button onClick={() => onMessage(user.id)}>Message</button>
    </div>
  );
}

function UserSocialInfo({ user, onFollow, onBlock }) {
  return (
    <div className="user-social-info">
      <p>Followers: {user.followers}</p>
      <button onClick={() => onFollow(user.id)}>Follow</button>
      <button onClick={() => onBlock(user.id)}>Block</button>
    </div>
  );
}

// Compose them as needed
function UserProfile({ user, onEdit, onMessage, onFollow, onBlock }) {
  return (
    <div className="user-profile">
      <UserBasicInfo user={user} onEdit={onEdit} />
      <UserContactInfo user={user} onMessage={onMessage} />
      <UserSocialInfo user={user} onFollow={onFollow} onBlock={onBlock} />
    </div>
  );
}

5. Use Higher-Order Components for Cross-Cutting Concerns (DIP)

// HOC for authentication
function withAuth(WrappedComponent) {
  return function AuthenticatedComponent(props) {
    const { user, loading } = useAuth();

    if (loading) return <div>Loading...</div>;
    if (!user) return <div>Please log in</div>;

    return <WrappedComponent {...props} user={user} />;
  };
}

// Usage
const ProtectedUserProfile = withAuth(UserProfile);

Benefits of Applying SOLID Principles in React

  1. Maintainability: Easier to modify and extend components
  2. Testability: Components with single responsibilities are easier to test
  3. Reusability: Well-designed components can be reused across different contexts
  4. Scalability: Clean architecture supports growing applications
  5. Debugging: Isolated responsibilities make issues easier to track down

Conclusion

Applying SOLID principles to React development leads to more maintainable, scalable, and robust applications. While these principles were originally designed for object-oriented programming, they translate beautifully to React's component-based architecture.

Start by identifying components that have multiple responsibilities and gradually refactor them. Use custom hooks to extract business logic, create focused component interfaces, and leverage dependency injection for better testability.

Remember: the goal isn't to follow SOLID principles dogmatically, but to use them as guidelines to create better, more maintainable React applications. The key is finding the right balance between clean architecture and practical development needs.

Happy coding! 🚀

Back to Category