SOLID Principles in React

Hi 👋, I am Dipankar Paul, aspiring Full Stack Developer with a passion for learning new technologies. Currently, I am learning my front-end development and full-stack development through Apna College Delta. With a passion for creating innovative and user-friendly applications, I am excited to continue my journey in the tech industry.
The SOLID principles can be highly beneficial when developing React applications. Let's explore how each principle translates to React development with practical examples.
1. Single Responsibility Principle (SRP)
In React, this means each component should focus on doing one thing well. Components should have a single reason to change.
Example: Breaking Down a Complex Form
Violating SRP:
function UserProfilePage() {
const [user, setUser] = useState({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
// Fetch user data
useEffect(() => {
fetchUserData()
.then(data => {
setUser(data);
setIsLoading(false);
})
.catch(err => {
setError(err);
setIsLoading(false);
});
}, []);
// Form handling logic
const handleSubmit = (event) => {
event.preventDefault();
// Validation logic
// API submission logic
// Success/error handling
};
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
<h1>{user.name}'s Profile</h1>
<form onSubmit={handleSubmit}>
{/* Many form fields */}
<input type="text" value={user.name} onChange={/* ... */} />
<input type="email" value={user.email} onChange={/* ... */} />
<textarea value={user.bio} onChange={/* ... */} />
{/* More fields, validation display, etc. */}
<button type="submit">Save Profile</button>
</form>
</div>
);
}
Following SRP:
// Data fetching component
function UserProfileContainer() {
const [user, setUser] = useState({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUserData()
.then(data => {
setUser(data);
setIsLoading(false);
})
.catch(err => {
setError(err);
setIsLoading(false);
});
}, []);
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return <UserProfileForm user={user} />;
}
// Presentation and form handling component
function UserProfileForm({ user }) {
const [formData, setFormData] = useState(user);
const handleChange = (e) => {
setFormData({...formData, [e.target.name]: e.target.value});
};
const handleSubmit = (event) => {
event.preventDefault();
updateUserProfile(formData);
};
return (
<div>
<h1>{formData.name}'s Profile</h1>
<form onSubmit={handleSubmit}>
<ProfileField
label="Name"
name="name"
value={formData.name}
onChange={handleChange}
/>
<ProfileField
label="Email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
<BioField
value={formData.bio}
onChange={handleChange}
/>
<button type="submit">Save Profile</button>
</form>
</div>
);
}
// Reusable form field component
function ProfileField({ label, name, type = "text", value, onChange }) {
return (
<div className="form-field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
/>
</div>
);
}
This breakdown creates:
Container component handling data fetching
Form component handling form state and submission
Field component for reusable input rendering
Each has a single responsibility and reason to change.
2. Open/Closed Principle (OCP)
React components should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing components.
Example: Extensible Button Component
Following OCP:
// Base Button component that's closed for modification
function Button({ children, onClick, ...props }) {
return (
<button
onClick={onClick}
className="btn"
{...props}
>
{children}
</button>
);
}
// Extended components without modifying the base Button
function PrimaryButton(props) {
return <Button className="btn btn-primary" {...props} />;
}
function DangerButton(props) {
return <Button className="btn btn-danger" {...props} />;
}
function IconButton({ icon, ...props }) {
return (
<Button {...props}>
<span className="icon">{icon}</span>
{props.children}
</Button>
);
}
// Further extension via composition
function SubmitButton(props) {
return (
<PrimaryButton type="submit" {...props}>
Submit
</PrimaryButton>
);
}
The base Button component is closed for modification but open for extension through composition, props spreading, and specialization.
Example: Render Props Pattern for OCP
// A component that's closed for modification but extendable
function DataFetcher({ url, render }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(url)
.then(response => response.json())
.then(data => {
setData(data);
setLoading(false);
})
.catch(error => {
setError(error);
setLoading(false);
});
}, [url]);
return render({ data, loading, error });
}
// Different ways to extend without modifying DataFetcher
function UserList() {
return (
<DataFetcher
url="/api/users"
render={({ data, loading, error }) => {
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return <UserTable users={data} />;
}}
/>
);
}
function ProductDetails({ productId }) {
return (
<DataFetcher
url={`/api/products/${productId}`}
render={({ data, loading, error }) => {
if (loading) return <ProductSkeleton />;
if (error) return <ProductError />;
return <ProductView product={data} />;
}}
/>
);
}
The DataFetcher component is closed for modification but can be extended for many different use cases through the render prop pattern.
3. Liskov Substitution Principle (LSP)
In React, this means child components should be substitutable for their parent components without affecting the correctness of the application.
Example: Component Inheritance/Composition
// Base Card component with a consistent interface
function Card({ title, children, footer }) {
return (
<div className="card">
{title && <div className="card-header">{title}</div>}
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
// UserCard extends Card functionality while remaining substitutable
function UserCard({ user, ...props }) {
return (
<Card
title={<UserHeader user={user} />}
footer={<UserFooter user={user} />}
{...props}
>
<UserDetails user={user} />
</Card>
);
}
// ProductCard also extends Card and remains substitutable
function ProductCard({ product, ...props }) {
return (
<Card
title={<ProductHeader product={product} />}
footer={<PriceFooter price={product.price} />}
{...props}
>
<ProductDetails product={product} />
</Card>
);
}
// This function can work with ANY type of Card component
function CardGrid({ items, CardComponent }) {
return (
<div className="card-grid">
{items.map(item => (
<CardComponent key={item.id} {...item} />
))}
</div>
);
}
// Usage
<CardGrid items={users} CardComponent={UserCard} />
<CardGrid items={products} CardComponent={ProductCard} />
Any type of Card component can be substituted in the CardGrid without breaking its functionality, adhering to LSP.
4. Interface Segregation Principle (ISP)
Components should not be forced to depend on props they don't use. It's better to have multiple smaller, focused components than one large component with many props.
Example: Breaking Down a Complex Component
Violating ISP:
function DataTable({
data,
columns,
sortable,
sortColumn,
sortDirection,
onSort,
filterable,
filters,
onFilter,
pageable,
page,
pageSize,
totalPages,
onPageChange,
selectable,
selectedItems,
onSelect,
editable,
onEdit,
deletable,
onDelete,
// Many more props...
}) {
// Complex implementation handling all these features
return (
<div>
{/* Table implementation */}
</div>
);
}
Following ISP:
// Core table component with minimal interface
function DataTable({ data, columns, children }) {
return (
<table>
<thead>
<tr>
{columns.map(column => (
<th key={column.key}>{column.title}</th>
))}
</tr>
</thead>
<tbody>
{data.map(row => (
<tr key={row.id}>
{columns.map(column => (
<td key={column.key}>{row[column.key]}</td>
))}
</tr>
))}
</tbody>
{children}
</table>
);
}
// Specialized components with focused interfaces
function SortableHeader({ column, sortDirection, onSort }) {
return (
<th onClick={() => onSort(column.key)}>
{column.title} {sortDirection === 'asc' ? '↑' : '↓'}
</th>
);
}
function TablePagination({ page, totalPages, onPageChange }) {
return (
<tfoot>
<tr>
<td colSpan="100%">
<div className="pagination">
<button onClick={() => onPageChange(page - 1)} disabled={page === 1}>
Previous
</button>
<span>Page {page} of {totalPages}</span>
<button onClick={() => onPageChange(page + 1)} disabled={page === totalPages}>
Next
</button>
</div>
</td>
</tr>
</tfoot>
);
}
// Compose them as needed without forcing unused props
function ProductTable({ products }) {
// Only implement pagination, no sorting or filtering
const [page, setPage] = useState(1);
const pageSize = 10;
const totalPages = Math.ceil(products.length / pageSize);
const displayProducts = products.slice((page - 1) * pageSize, page * pageSize);
return (
<DataTable
data={displayProducts}
columns={productColumns}
>
<TablePagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</DataTable>
);
}
Each component has a focused interface with only the props it needs, following ISP.
5. Dependency Inversion Principle (DIP)
High-level components should not depend on low-level components. Both should depend on abstractions.
Example: Isolating API Dependencies
Violating DIP:
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
// Direct dependency on API implementation
fetch('/api/users')
.then(response => response.json())
.then(data => setUsers(data));
}, []);
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Following DIP:
// Abstract API service
const userService = {
getUsers: () => fetch('/api/users').then(res => res.json()),
getUser: (id) => fetch(`/api/users/${id}`).then(res => res.json()),
updateUser: (user) => fetch(`/api/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify(user),
headers: { 'Content-Type': 'application/json' }
}).then(res => res.json())
};
// Custom hook that depends on abstractions
function useUserData() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
userService.getUsers()
.then(data => {
setUsers(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
return { users, loading, error };
}
// Component depends on the abstraction, not the implementation
function UserList() {
const { users, loading, error } = useUserData();
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Example: Dependency Injection in React with Context
// Create an abstract API context
const ApiContext = React.createContext(null);
// Provider component that injects the dependency
function ApiProvider({ apiClient, children }) {
return (
<ApiContext.Provider value={apiClient}>
{children}
</ApiContext.Provider>
);
}
// Hook to consume the abstraction
function useApi() {
const api = useContext(ApiContext);
if (!api) {
throw new Error('useApi must be used within an ApiProvider');
}
return api;
}
// Component that depends on the abstraction
function UserList() {
const api = useApi();
const [users, setUsers] = useState([]);
useEffect(() => {
api.getUsers().then(setUsers);
}, [api]);
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// App setup with dependency injection
function App() {
// We can inject different implementations
const productionApi = {
getUsers: () => fetch('/api/users').then(res => res.json())
};
const mockApi = {
getUsers: () => Promise.resolve([
{ id: 1, name: 'Test User' }
])
};
// Choose which implementation to inject
const api = process.env.NODE_ENV === 'test' ? mockApi : productionApi;
return (
<ApiProvider apiClient={api}>
<UserList />
</ApiProvider>
);
}
This approach follows DIP by:
Creating an abstraction (the API context)
High-level components (UserList) depend on the abstraction, not concrete implementations
We can easily inject different implementations without changing the components
Practical Applications of SOLID in React
Custom Hooks for SRP and DIP
// Separate concerns with custom hooks
function useUserApi() {
const api = useApi();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchUsers = useCallback(() => {
setLoading(true);
api.getUsers()
.then(data => {
setUsers(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [api]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
return { users, loading, error, refetch: fetchUsers };
}
// Component just handles presentation
function UserList() {
const { users, loading, error, refetch } = useUserApi();
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
return (
<div>
<h2>Users</h2>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
Higher-Order Components for OCP
// HOC that adds loading state to any component
function withLoading(WrappedComponent) {
return function WithLoadingComponent({ isLoading, ...props }) {
if (isLoading) return <LoadingSpinner />;
return <WrappedComponent {...props} />;
};
}
// Usage
const UserListWithLoading = withLoading(UserList);
Compound Components for ISP
function Tabs({ children, defaultTab }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs-container">
{children}
</div>
</TabsContext.Provider>
);
}
function TabList({ children }) {
return <div className="tab-list">{children}</div>;
}
function Tab({ id, children }) {
const { activeTab, setActiveTab } = useContext(TabsContext);
return (
<button
className={activeTab === id ? 'active' : ''}
onClick={() => setActiveTab(id)}
>
{children}
</button>
);
}
function TabPanels({ children }) {
return <div className="tab-panels">{children}</div>;
}
function TabPanel({ id, children }) {
const { activeTab } = useContext(TabsContext);
if (activeTab !== id) return null;
return <div className="tab-panel">{children}</div>;
}
// Assign as compound components
Tabs.TabList = TabList;
Tabs.Tab = Tab;
Tabs.TabPanels = TabPanels;
Tabs.TabPanel = TabPanel;
// Usage
function App() {
return (
<Tabs defaultTab="users">
<Tabs.TabList>
<Tabs.Tab id="users">Users</Tabs.Tab>
<Tabs.Tab id="products">Products</Tabs.Tab>
</Tabs.TabList>
<Tabs.TabPanels>
<Tabs.TabPanel id="users">
<UserList />
</Tabs.TabPanel>
<Tabs.TabPanel id="products">
<ProductList />
</Tabs.TabPanel>
</Tabs.TabPanels>
</Tabs>
);
}
Benefits of SOLID in React
Applying SOLID principles to React applications yields several benefits:
Maintainability: Components with single responsibilities are easier to understand and modify.
Reusability: Well-designed components with clear interfaces can be reused across projects.
Testability: Components that follow SOLID are generally easier to test in isolation.
Scalability: Applications built with SOLID principles can grow without becoming unwieldy.
Adaptability: When requirements change, SOLID components are easier to adapt.
Challenges and Considerations
Over-engineering: Be careful not to break down components too much, which can lead to "prop drilling" or unnecessary complexity.
Performance: Too many small components might impact performance due to the overhead of component mounting/unmounting.
Learning curve: SOLID principles require discipline and understanding, which might slow down initial development.
Team alignment: The whole team needs to understand and follow these principles for consistency.
Conclusion
The SOLID principles provide a robust framework for designing React applications. By following these principles, you can create more maintainable, scalable, and testable React code. Each principle addresses different aspects of component design:
Single Responsibility: Each component should do one thing well
Open/Closed: Extend components through composition without modifying them
Liskov Substitution: Component variants should be interchangeable
Interface Segregation: Components should have focused, minimal props
Dependency Inversion: Components should depend on abstractions
When these principles are applied thoughtfully, they can significantly improve the quality and longevity of your React applications.




