S Single Responsibility Principle SRP · How it works

1 min read
Mid-level3 min read
Rapid overview

How it works

S — Single Responsibility Principle (SRP)

"A class/module/component should have one and only one reason to change."

❌ Bad example (React):

// UserProfile.jsx - violates SRP
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  // Data fetching logic
  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);

  // Validation logic
  const validateEmail = (email) => {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  };

  // Form submission logic
  const handleSubmit = (formData) => {
    if (!validateEmail(formData.email)) return;
    fetch('/api/users', {
      method: 'PUT',
      body: JSON.stringify(formData)
    });
  };

  // Analytics tracking
  useEffect(() => {
    window.gtag('event', 'profile_view', { userId });
  }, [userId]);

  // UI rendering
  return (
    <div>
      {loading && <Spinner />}
      {user && <form onSubmit={handleSubmit}>...</form>}
    </div>
  );
}

One component does too much: data fetching, validation, form handling, analytics, and UI rendering. Changing any of these responsibilities forces changes to the component.

✅ Good example (React):

// hooks/useUser.js - Data fetching responsibility
export function useUser(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);

  return { user, loading };
}

// utils/validators.js - Validation responsibility
export const emailValidator = (email) => {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};

// services/userService.js - API communication responsibility
export const userService = {
  updateUser: async (userData) => {
    return fetch('/api/users', {
      method: 'PUT',
      body: JSON.stringify(userData)
    });
  }
};

// hooks/useAnalytics.js - Analytics responsibility
export function usePageView(eventName, data) {
  useEffect(() => {
    window.gtag('event', eventName, data);
  }, [eventName, JSON.stringify(data)]);
}

// components/UserProfile.jsx - UI rendering responsibility only
function UserProfile({ userId }) {
  const { user, loading } = useUser(userId);
  usePageView('profile_view', { userId });

  const handleSubmit = async (formData) => {
    if (!emailValidator(formData.email)) {
      return;
    }
    await userService.updateUser(formData);
  };

  if (loading) return <Spinner />;
  if (!user) return null;

  return <UserForm user={user} onSubmit={handleSubmit} />;
}

👉 Each module/hook/component has one clear responsibility — easier to test, maintain, and evolve.

✅ Good example (Angular):

// services/user.service.ts - Data access responsibility
@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);

  getUser(userId: string): Observable<User> {
    return this.http.get<User>(`/api/users/${userId}`);
  }

  updateUser(userData: User): Observable<User> {
    return this.http.put<User>('/api/users', userData);
  }
}

// validators/email.validator.ts - Validation responsibility
export function emailValidator(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(control.value);
    return valid ? null : { invalidEmail: true };
  };
}

// services/analytics.service.ts - Analytics responsibility
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
  trackEvent(eventName: string, data: any): void {
    window.gtag('event', eventName, data);
  }
}

// components/user-profile/user-profile.component.ts - UI coordination only
@Component({
  selector: 'app-user-profile',
  templateUrl: './user-profile.component.html'
})
export class UserProfileComponent implements OnInit {
  private userService = inject(UserService);
  private analytics = inject(AnalyticsService);

  user$ = this.userService.getUser(this.userId);
  @Input() userId!: string;

  ngOnInit() {
    this.analytics.trackEvent('profile_view', { userId: this.userId });
  }

  onSubmit(formData: User) {
    this.userService.updateUser(formData).subscribe();
  }
}

✅ Good example (Vanilla JS/TypeScript):

// api/userApi.ts - HTTP communication responsibility
export class UserApi {
  async getUser(userId: string): Promise<User> {
    const response = await fetch(`/api/users/${userId}`);
    return response.json();
  }

  async updateUser(userData: User): Promise<User> {
    const response = await fetch('/api/users', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(userData)
    });
    return response.json();
  }
}

// validation/emailValidator.ts - Validation responsibility
export class EmailValidator {
  validate(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }
}

// analytics/tracker.ts - Analytics responsibility
export class AnalyticsTracker {
  trackPageView(eventName: string, data: Record<string, any>): void {
    window.gtag?.('event', eventName, data);
  }
}

// ui/UserProfileView.ts - UI rendering responsibility
export class UserProfileView {
  constructor(
    private userApi: UserApi,
    private validator: EmailValidator,
    private analytics: AnalyticsTracker
  ) {}

  async render(userId: string): Promise<void> {
    this.analytics.trackPageView('profile_view', { userId });
    const user = await this.userApi.getUser(userId);
    // Render UI...
  }

  async handleSubmit(formData: User): Promise<void> {
    if (!this.validator.validate(formData.email)) {
      return;
    }
    await this.userApi.updateUser(formData);
  }
}

💡 In frontend applications:

  • Separate data fetching (hooks/services), validation (validators/utils), UI rendering (components), and side effects (analytics/logging).
  • Each layer can evolve independently (e.g., switching from REST to GraphQL, changing validation rules, updating UI without touching business logic).
  • React: Use custom hooks for data/side effects, utility functions for pure logic, components for UI only.
  • Angular: Use services for data/business logic, validators for validation, components for UI coordination.
  • Vanilla JS: Use classes or modules to separate concerns clearly.