S Single Responsibility Principle SRP

6 min read
Rapid overview

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.

Questions & Answers

Q: How do you recognize SRP violations in React components?

A: When a component handles data fetching, validation, form state, analytics, and rendering all in one place. High line count (>200 lines), multiple useEffect hooks with different purposes, and mixing UI with business logic are red flags.

Q: How does SRP improve deploy cadence in frontend apps?

A: Focused modules let teams modify one area without risk to others—changing a validation rule doesn't require retesting data fetching logic. Reduces merge conflicts and enables independent feature deployments.

Q: Can a component coordinate other components and still obey SRP?

A: Yes, if its sole responsibility is composition/coordination. For example, a CheckoutFlow component can orchestrate cart, payment, and confirmation components; its responsibility is orchestration, not implementing checkout logic itself.

Q: How does SRP influence folder structure in frontend projects?

A: Group files by feature/domain, not by type. For example:

features/
  user-profile/
    UserProfile.tsx
    useUser.ts
    userService.ts
    userValidators.ts

This keeps responsibilities cohesive and discoverable.

Q: What role do hooks play in SRP (React)?

A: Custom hooks extract reusable logic with focused responsibilities (useUser, useAuth, useAnalytics), ensuring components stay focused on UI rendering and composition.

Q: What role do services play in SRP (Angular)?

A: Services encapsulate single responsibilities (UserService for API calls, AuthService for authentication, LoggingService for logging), keeping components focused on UI coordination via dependency injection.

  1. Move data fetching to custom hooks
  2. Extract validation to utility functions
  3. Move API calls to service modules
  4. Extract analytics/logging to dedicated hooks
Q: How do you refactor SRP violations safely in React?

A: Extract logic incrementally:

Add unit tests for each extracted piece.

Q: Can modules (not just components) violate SRP?

A: Absolutely. A utility module that mixes date formatting, API calls, and validation also violates SRP. Break into focused modules: dateUtils.ts, apiClient.ts, validators.ts.

Q: How does SRP impact bundle size?

A: Focused modules enable better tree-shaking—unused validation logic or analytics code can be eliminated if components don't import them. Mixing responsibilities makes dead code elimination harder.

  • Pure validators can be unit tested in isolation
  • Data hooks can be tested with mock APIs
  • Components can be tested with mock dependencies
  • No need to set up entire component trees to test validation logic
Q: How does SRP improve testability in frontend?

A: Testing becomes easier:

Q: What tooling catches SRP issues in frontend code?

A: ESLint rules (max-lines, complexity), code review checklists, and architecture tests. Tools like dependency-cruiser can enforce module boundaries.

Q: How do you communicate SRP to product teams?

A: Describe it as separating concerns like a restaurant: the waiter doesn't cook (UI doesn't handle business logic), the chef doesn't serve (business logic doesn't render), the host doesn't wash dishes (coordination doesn't include implementation details).

Q: How does SRP apply to state management (Redux, Zustand, etc.)?

A: Separate concerns: actions for events, reducers for state transitions, selectors for derived data, middleware for side effects. Each piece has one clear responsibility, making the state machine predictable and testable.