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.

Full-Stack Cultivation Path
Full-Stack Cultivation Path
Full-Stack Cultivation Path
Applying SOLID Principles to Frontend Development: Practical React and Angular Examples

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" />;
Button

expects 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; }
}
Square

and 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'); }
}
RobotWorker

is 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>;
}
UserComponent

is 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

TypeScriptJavaScriptFrontend DevelopmentReactSOLIDAngular
Full-Stack Cultivation Path
Written by

Full-Stack Cultivation Path

Focused on sharing practical tech content about TypeScript, Vue 3, front-end architecture, and source code analysis.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.