Single Responsibility
Ensure each component, module, or function has one clear purpose and reason to change.
In this article
Single Responsibility
Problem
We’ve all seen the 1,500-line component that fetches data, validates inputs, formats dates, manages auth state, and renders six different UI sections. When you need to change how a date displays, you’re scrolling past authentication logic and hoping you don’t break something.
I call these “kitchen sink” components, and they’re painful to work with. Testing email validation means mocking the API, setting up authentication, and rendering the entire component just to check if ”@” is present.
It gets worse as your team grows. Two developers can’t work on the same feature without constant merge conflicts. A bug fix in validation logic somehow breaks navigation. Nobody wants to touch the file because everyone’s been burned before.
Solution
I follow a simple rule: if you can’t describe what a function or component does in one sentence without using “and,” it’s doing too much. A function that fetches user data should only fetch it, without validating or formatting it. A component that renders a form should just render, receiving data through props and emitting events when users interact.
When you follow this rule, each kind of change stays in one file, whether it’s a change to the API response, a new validation rule, or a new button style.
A quick check is to describe each of your functions in a single sentence: “This function validates email addresses.” “This component renders a user avatar.” If the sentence keeps needing “and,” the code probably needs splitting.
Example
The before-and-after below shows a component doing too much, then the same component broken into focused pieces.
Before: Multiple Responsibilities
// ❌ Everything crammed into one component
function UserProfile({ userId, currentUserId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [errors, setErrors] = useState({});
// Data fetching
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => { setUser(data); setLoading(false); });
}, [userId]);
// Validation
const validateForm = (data) => {
const errs = {};
if (!data.email.includes('@')) errs.email = 'Invalid email';
if (data.age < 18) errs.age = 'Must be 18+';
return errs;
};
// Formatting
const formatDate = (date) => new Date(date).toLocaleDateString('en-US');
// Auth logic
const canEdit = () => user.role === 'admin' || user.id === currentUserId;
// Submit handler
const handleSubmit = async (e) => {
e.preventDefault();
const validationErrors = validateForm(user);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
await fetch(`/api/users/${userId}`, { method: 'PUT', body: JSON.stringify(user) });
};
if (loading) return <div>Loading...</div>;
return (
<div className="profile">
<h1>{user.name}</h1>
<p>Joined: {formatDate(user.createdAt)}</p>
{canEdit() && (
<form onSubmit={handleSubmit}>
<input value={user.email} onChange={e => setUser({...user, email: e.target.value})} />
{errors.email && <span>{errors.email}</span>}
<button>Save</button>
</form>
)}
</div>
);
}
After: Single Responsibilities
// ✅ Hook: data fetching only
function useUser(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => { setUser(data); setLoading(false); });
}, [userId]);
return { user, loading };
}
// ✅ Pure functions: one job each
const validateUserForm = (data) => {
const errors = {};
if (!data.email?.includes('@')) errors.email = 'Invalid email';
if (data.age < 18) errors.age = 'Must be 18+';
return errors;
};
const formatDate = (date) => new Date(date).toLocaleDateString('en-US');
const canEditProfile = (user, currentUserId) => user.role === 'admin' || user.id === currentUserId;
const updateUser = (userId, data) => fetch(`/api/users/${userId}`, { method: 'PUT', body: JSON.stringify(data) });
// ✅ Component: display only
function UserInfo({ user }) {
return (
<div>
<h1>{user.name}</h1>
<p>Joined: {formatDate(user.createdAt)}</p>
</div>
);
}
// ✅ Component: form only
function UserEditForm({ user, errors, onSubmit, onChange }) {
return (
<form onSubmit={onSubmit}>
<input value={user.email} onChange={e => onChange('email', e.target.value)} />
{errors.email && <span>{errors.email}</span>}
<button type="submit">Save</button>
</form>
);
}
// ✅ Container: coordinates the pieces
function UserProfile({ userId, currentUserId }) {
const { user, loading } = useUser(userId);
const [editedUser, setEditedUser] = useState(null);
const [errors, setErrors] = useState({});
useEffect(() => { if (user) setEditedUser(user); }, [user]);
const handleSubmit = async (e) => {
e.preventDefault();
const validationErrors = validateUserForm(editedUser);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
await updateUser(userId, editedUser);
};
if (loading || !editedUser) return <div>Loading...</div>;
return (
<div className="profile">
<UserInfo user={user} />
{canEditProfile(user, currentUserId) && (
<UserEditForm
user={editedUser}
errors={errors}
onSubmit={handleSubmit}
onChange={(field, val) => setEditedUser(prev => ({ ...prev, [field]: val }))}
/>
)}
</div>
);
}
At the Function Level
The same principle applies to functions; separating fetching from transforming allows each to change independently:
// ❌ Fetching AND transforming
async function getUserData(userId) {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return {
...data,
fullName: `${data.firstName} ${data.lastName}`,
age: new Date().getFullYear() - new Date(data.birthDate).getFullYear()
};
}
// ✅ Separate concerns
async function fetchUser(userId) {
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
function transformUser(userData) {
return {
...userData,
fullName: `${userData.firstName} ${userData.lastName}`,
age: new Date().getFullYear() - new Date(userData.birthDate).getFullYear()
};
}
// Usage
const rawUser = await fetchUser(userId);
const user = transformUser(rawUser);
Benefits
- Immediate clarity: you can open any file and see what it does in seconds.
- Simple testing: a function that does one thing needs one focused test, with no complex setup and no mocking everything around it.
- Contained changes: updating validation rules leaves everything else untouched, so one change doesn’t spread into unrelated code.
- Real reuse: a date formatting function that only formats dates can be used anywhere.
- Faster debugging: “the date is wrong” means checking one file, not five.
- Parallel work: different developers own different responsibilities without constant merge conflicts.
Tradeoffs
- More files: you’ll end up with a lot more navigation, imports, and things to name.
- Harder to trace: following code flow across many modules takes more effort, and sometimes you just want to see everything in one place.
- Subjective boundaries: deciding what counts as “one responsibility” is tricky, and reasonable people disagree.
- Easy to overdo: I’ve seen codebases where a three-line function got its own file.
- Onboarding cost: more abstractions mean more things for new developers to learn.
- Team alignment: without agreed-upon boundaries, you’ll have inconsistent patterns scattered everywhere.
Summary
Single responsibility means each function, component, or module does exactly one thing describable in a single sentence. When code has one reason to change, changes stay small, testing becomes simple, and reuse works. The hard part is recognizing when something is doing too much, and splitting it before the complexity builds up.