Applying SOLID Principles to Frontend Development: Practical React and Angular Examples
This article explains the five SOLID design principles, shows how they are often violated in React and Angular code, and provides concrete refactorings—including custom hooks, separate services, validator classes, and interface segregation—to make frontend applications more maintainable, testable, and extensible.
Introduction
The SOLID principles were introduced by Robert C. Martin in 2000 as a set of guidelines for object‑oriented programming. Although originally aimed at classic OOP languages, they have been adopted in modern JavaScript and TypeScript frameworks such as React and Angular to reduce complexity and improve maintainability.
The Five Principles
Single Responsibility Principle (SRP)
Open/Closed Principle (OCP)
Liskov Substitution Principle (LSP)
Interface Segregation Principle (ISP)
Dependency Inversion Principle (DIP)
1. Single Responsibility Principle (SRP)
Problem
In frontend development a component often handles UI rendering, business logic, and data fetching simultaneously, which makes the code hard to maintain and test.
Bad Example (React)
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUserData();
}, [userId]);
async function fetchUserData() {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
}
return <div>{user?.name}</div>;
}The UserProfile component mixes UI rendering with data fetching, violating SRP.
Refactored Code
// Custom hook for fetching user data
function useUserData(userId) {
const [user, setUser] = useState(null);
useEffect(() => {
async function fetchUserData() {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
}
fetchUserData();
}, [userId]);
return user;
}
// UI component
function UserProfile({ userId }) {
const user = useUserData(userId); // data fetching moved to hook
return <div>{user?.name}</div>;
}By extracting data fetching into a custom hook, the component focuses solely on UI rendering, satisfying SRP.
Bad Example (Angular)
@Injectable()
export class UserService {
constructor(private http: HttpClient) {}
getUser(userId: string) {
return this.http.get(`/api/users/${userId}`);
}
updateUserProfile(userId: string, data: any) {
return this.http.put(`/api/users/${userId}`, data).subscribe(() => {
console.log('User updated');
alert('Profile updated successfully');
});
}
}The UserService handles fetching, updating, and notification logic, breaching SRP.
Refactored Code
@Injectable()
export class UserService {
constructor(private http: HttpClient) {}
getUser(userId: string) {
return this.http.get(`/api/users/${userId}`);
}
updateUserProfile(userId: string, data: any) {
return this.http.put(`/api/users/${userId}`, data);
}
}
@Injectable()
export class NotificationService {
notify(message: string) {
alert(message); // hypothetical showModal could be used here
}
}Clear responsibilities improve maintainability.
Notification logic can be reused across services.
Separate tests can be written for UserService and NotificationService.
Extending notification methods only requires changes in NotificationService.
2. Open/Closed Principle (OCP)
Problem
A validation function that contains all rules must be edited whenever a new rule is added, violating OCP.
Bad Example (React)
function validateForm(values) {
let errors = {};
if (!values.name) {
errors.name = "Name is required";
}
if (!values.email) {
errors.email = "Email is required";
} else if (!/\S+@\S+\.\S+/.test(values.email)) {
errors.email = "Email is invalid";
}
return errors;
}Refactored Code
// Base validator interface
class Validator {
validate(value) {
throw new Error("validate method must be implemented");
}
}
class RequiredValidator extends Validator {
validate(value) {
return value ? null : "This field is required";
}
}
class EmailValidator extends Validator {
validate(value) {
return /\S+@\S+\.\S+/.test(value) ? null : "Email is invalid";
}
}
function validateForm(values, validators) {
let errors = {};
for (let field in validators) {
const error = validators[field].validate(values[field]);
if (error) {
errors[field] = error;
}
}
return errors;
}
// Usage example
const validators = {
name: new RequiredValidator(),
email: new EmailValidator(),
};
const errors = validateForm({ name: "", email: "invalid email" }, validators);
console.log(errors);New validation rules can be added by creating a new validator class without touching existing code.
Bad Example (Angular)
@Injectable()
export class NotificationService {
send(type: 'email' | 'sms', message: string) {
if (type === 'email') {
// send email
} else if (type === 'sms') {
// send sms
}
}
}Adding a new notification type requires modifying send, breaking OCP.
Refactored Code
interface Notification {
send(message: string): void;
}
@Injectable()
export class EmailNotification implements Notification {
send(message: string) {
// email logic
}
}
@Injectable()
export class SMSNotification implements Notification {
send(message: string) {
// sms logic
}
}
@Injectable()
export class NotificationService {
constructor(private notifications: Notification[]) {}
notify(message: string) {
this.notifications.forEach(n => n.send(message));
}
}The design is open for extension (new notification classes) but closed for modification of existing code.
3. Liskov Substitution Principle (LSP)
Problem
Components with incompatible interfaces cannot be substituted safely.
Bad Example (React)
function Button({ onClick }) {
return <button onClick={onClick}>Click me</button>;
}
function LinkButton({ href }) {
return <a href={href}>Click me</a>;
}
<Button onClick={() => {}} />;
<LinkButton href="/home" />; Buttonexpects onClick while LinkButton expects href, making substitution difficult.
Refactored Code
function Clickable({ children, onClick }) {
return <div onClick={onClick}>{children}</div>;
}
function Button({ onClick }) {
return <Clickable onClick={onClick}>
<button>Click me</button>
</Clickable>;
}
function LinkButton({ href }) {
return <Clickable onClick={() => window.location.href = href}>
<a href={href}>Click me</a>
</Clickable>;
}Both components now share the same Clickable contract, satisfying LSP.
Bad Example (Angular)
class Rectangle {
constructor(protected width: number, protected height: number) {}
area() { return this.width * this.height; }
}
class Square extends Rectangle {
constructor(size: number) { super(size, size); }
setWidth(width: number) {
this.width = width;
this.height = width; // Breaks LSP
}
}Changing Square width also changes height, violating LSP.
Refactored Code
class Shape {
area(): number { throw new Error('Method not implemented'); }
}
class Rectangle extends Shape {
constructor(private width: number, private height: number) { super(); }
area() { return this.width * this.height; }
}
class Square extends Shape {
constructor(private size: number) { super(); }
area() { return this.size * this.size; }
} Squareand Rectangle now conform to the same contract without breaking behavior.
4. Interface Segregation Principle (ISP)
Problem
Clients are forced to depend on interfaces they do not use.
Bad Example (React)
function MultiPurposeComponent({ user, posts, comments }) {
return (
<div>
<UserProfile user={user} />
<UserPosts posts={posts} />
<UserComments comments={comments} />
</div>
);
}The component receives props it may never use, creating tight coupling.
Refactored Code
function UserProfileComponent({ user }) {
return <UserProfile user={user} />;
}
function UserPostsComponent({ posts }) {
return <UserPosts posts={posts} />;
}
function UserCommentsComponent({ comments }) {
return <UserComments comments={comments} />;
}Each component depends only on the data it actually needs.
Bad Example (Angular)
interface Worker {
work(): void;
eat(): void;
}
class HumanWorker implements Worker {
work() { console.log('Working'); }
eat() { console.log('Eating'); }
}
class RobotWorker implements Worker {
work() { console.log('Working'); }
eat() { throw new Error('Robots do not eat'); }
} RobotWorkeris forced to implement an irrelevant eat method.
Refactored Code
interface Worker { work(): void; }
interface Eater { eat(): void; }
class HumanWorker implements Worker, Eater {
work() { console.log('Working'); }
eat() { console.log('Eating'); }
}
class RobotWorker implements Worker {
work() { console.log('Working'); }
}Clients now depend only on the interfaces they require.
5. Dependency Inversion Principle (DIP)
Problem
High‑level modules depend directly on low‑level implementations.
Bad Example (React)
function fetchUser(userId) {
return fetch(`/api/users/${userId}`).then(res => res.json());
}
function UserComponent({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
return <div>{user?.name}</div>;
} UserComponentis tightly coupled to fetchUser.
Refactored Code
function UserComponent({ userId, fetchUserData }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUserData(userId).then(setUser);
}, [userId, fetchUserData]);
return <div>{user?.name}</div>;
}
// Usage
<UserComponent userId={1} fetchUserData={fetchUser} />;Injecting the data‑fetching function allows swapping implementations for testing or other use cases.
Bad Example (Angular)
@Injectable()
export class UserService {
constructor(private http: HttpClient) {}
getUser(userId: string) { return this.http.get(`/api/users/${userId}`); }
}
@Injectable()
export class UserComponent {
constructor(private userService: UserService) {}
loadUser(userId: string) {
this.userService.getUser(userId).subscribe(user => console.log(user));
}
}The component depends on a concrete UserService implementation.
Refactored Code
interface UserService {
getUser(userId: string): Observable<User>;
}
@Injectable()
export class ApiUserService implements UserService {
constructor(private http: HttpClient) {}
getUser(userId: string) {
return this.http.get<User>(`/api/users/${userId}`);
}
}
@Injectable()
export class UserComponent {
constructor(private userService: UserService) {}
loadUser(userId: string) {
this.userService.getUser(userId).subscribe(user => console.log(user));
}
}Depending on the abstraction UserService decouples the component from the concrete API implementation.
Conclusion
Whether using React, Angular, or even backend Node.js, applying the SOLID principles provides a solid architectural guide. They help developers write clean, maintainable, and extensible code in JavaScript and TypeScript ecosystems.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Full-Stack Cultivation Path
Focused on sharing practical tech content about TypeScript, Vue 3, front-end architecture, and source code analysis.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
