S Single Responsibility Principle SRP · How it works
1 min readRapid 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.