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!
SOLID is an acronym for five design principles that make software designs more understandable, flexible, and maintainable:
Let's explore how each principle applies to React development with practical examples.
"A class should have a single responsibility"
In React, this translates to: Each component should have only one responsibility.
// 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>
);
}
// 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} />;
}
"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.
// 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>
);
}
"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.
// 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>
);
}
"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: 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
}
// 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>
);
}
"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.
// 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>
);
}
// 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 };
}
// 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>;
// 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>
);
}
// 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>
);
}
// 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);
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! 🚀
Let me know what you think