S Single Responsibility Principle SRP
6 min readS — 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
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.
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.
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.
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.
A: Custom hooks extract reusable logic with focused responsibilities (useUser, useAuth, useAnalytics), ensuring components stay focused on UI rendering and composition.
A: Services encapsulate single responsibilities (UserService for API calls, AuthService for authentication, LoggingService for logging), keeping components focused on UI coordination via dependency injection.
- Move data fetching to custom hooks
- Extract validation to utility functions
- Move API calls to service modules
- Extract analytics/logging to dedicated hooks
A: Extract logic incrementally:
Add unit tests for each extracted piece.
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.
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
A: Testing becomes easier:
A: ESLint rules (max-lines, complexity), code review checklists, and architecture tests. Tools like dependency-cruiser can enforce module boundaries.
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).
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.