Single Responsibility
Problem
Components grow into thousand-line files that handle data fetching, validation, formatting, UI rendering, and business logic simultaneously, making it impossible to change one aspect without understanding and potentially breaking everything else. A single component fetches user data, validates form inputs, formats dates and currency, manages authentication state, handles error messages, coordinates navigation, and renders multiple UI sections all together, while testing becomes impossible because mocking requires setting up data fetching, business rules, formatting logic, and UI rendering simultaneously even when testing a single feature.
Changes to date formatting require reading through authentication logic and validation rules, while adding a new form field requires understanding data fetching, API error handling, and rendering logic. Multiple developers cannot work on the same component because all features are tangled together in one file, while bug fixes in validation logic risk breaking unrelated rendering code and performance optimization requires understanding every responsibility to avoid breaking unrelated features.
Solution
Design each module, component, or function to have exactly one reason to change, meaning one responsibility or concern that it handles. A function that fetches user data should only fetch data, not validate it or format it for display. A component that renders a form should only render, not fetch its own data or handle business logic. A validation function should only validate, not format error messages or trigger side effects. When requirements change, the modification should affect only the code responsible for that specific concern. If the API response format changes, only the data fetching layer changes. If validation rules change, only the validation functions change. If styling changes, only presentation components change. This creates focused, cohesive units that are easier to understand, test, and maintain without unintended side effects. Each unit has a clear purpose that can be described in a single sentence without using “and” to connect multiple concerns.
Example
This example demonstrates breaking apart a component with multiple responsibilities into focused functions and components where each has a single clear purpose and reason to change.
Component with Multiple Responsibilities
// ❌ MULTIPLE RESPONSIBILITIES: Everything in one component
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [errors, setErrors] = useState({});
// Responsibility 1: Data fetching
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
// Responsibility 2: Form validation
const validateForm = (data) => {
const newErrors = {};
if (!data.email.includes('@')) {
newErrors.email = 'Invalid email';
}
if (data.age < 18) {
newErrors.age = 'Must be 18 or older';
}
return newErrors;
};
// Responsibility 3: Data formatting
const formatDate = (date) => {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
// Responsibility 4: Business logic
const canEditProfile = (user) => {
return user.role === 'admin' || user.id === currentUserId;
};
// Responsibility 5: Event handling
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)
});
};
// Responsibility 6: UI rendering with complex logic
if (loading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<div className="profile">
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
<p>Joined: {formatDate(user.createdAt)}</p>
{canEditProfile(user) && (
<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>
);
}
Refactored with Single Responsibilities
// ✅ SINGLE RESPONSIBILITY: 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 };
}
// ✅ SINGLE RESPONSIBILITY: Validation rules only
const validateUserForm = (data) => {
const errors = {};
if (!data.email?.includes('@')) {
errors.email = 'Invalid email';
}
if (data.age < 18) {
errors.age = 'Must be 18 or older';
}
return errors;
};
// ✅ SINGLE RESPONSIBILITY: Date formatting only
const formatDate = (date) => {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
// ✅ SINGLE RESPONSIBILITY: Authorization logic only
const canEditProfile = (user, currentUserId) => {
return user.role === 'admin' || user.id === currentUserId;
};
// ✅ SINGLE RESPONSIBILITY: User update API call only
const updateUser = async (userId, userData) => {
return fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
};
// ✅ SINGLE RESPONSIBILITY: Display user information only
function UserInfo({ user }) {
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
<p>Joined: {formatDate(user.createdAt)}</p>
</div>
);
}
// ✅ SINGLE RESPONSIBILITY: Edit form only
function UserEditForm({ user, errors, onSubmit, onChange }) {
return (
<form onSubmit={onSubmit}>
<input
type="email"
value={user.email}
onChange={e => onChange('email', e.target.value)}
/>
{errors.email && <span className="error">{errors.email}</span>}
<input
type="number"
value={user.age}
onChange={e => onChange('age', parseInt(e.target.value))}
/>
{errors.age && <span className="error">{errors.age}</span>}
<button type="submit">Save</button>
</form>
);
}
// ✅ SINGLE RESPONSIBILITY: Coordinate other responsibilities
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 handleChange = (field, value) => {
setEditedUser(prev => ({ ...prev, [field]: value }));
};
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) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<div className="profile">
<UserInfo user={user} />
{canEditProfile(user, currentUserId) && (
<UserEditForm
user={editedUser}
errors={errors}
onSubmit={handleSubmit}
onChange={handleChange}
/>
)}
</div>
);
}
Function-Level Single Responsibility
// ❌ MULTIPLE RESPONSIBILITIES: Fetching and transforming
async function getUserData(userId) {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// Mixing data fetching with transformation
return {
...data,
fullName: `${data.firstName} ${data.lastName}`,
age: new Date().getFullYear() - new Date(data.birthDate).getFullYear()
};
}
// ✅ SINGLE RESPONSIBILITY: Fetching only
async function fetchUser(userId) {
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
// ✅ SINGLE RESPONSIBILITY: Transforming only
function transformUser(userData) {
return {
...userData,
fullName: `${userData.firstName} ${userData.lastName}`,
age: new Date().getFullYear() - new Date(userData.birthDate).getFullYear()
};
}
// Usage: Clear separation of concerns
const rawUser = await fetchUser(userId);
const transformedUser = transformUser(rawUser);
Hook-Level Single Responsibility
// ❌ MULTIPLE RESPONSIBILITIES: Data fetching + caching + error handling
function useUserWithCache(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const cache = useRef({});
useEffect(() => {
if (cache.current[userId]) {
setUser(cache.current[userId]);
setLoading(false);
return;
}
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
cache.current[userId] = data;
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [userId]);
return { user, loading, error };
}
// ✅ SINGLE RESPONSIBILITY: Each hook does one thing
function useCache() {
const cache = useRef({});
const get = (key) => cache.current[key];
const set = (key, value) => { cache.current[key] = value; };
return { get, set };
}
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(url)
.then(r => r.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return { data, loading, error };
}
function useUser(userId) {
const cache = useCache();
const cachedUser = cache.get(userId);
const { data, loading, error } = useFetch(
cachedUser ? null : `/api/users/${userId}`
);
useEffect(() => {
if (data) cache.set(userId, data);
}, [data, userId]);
return {
user: cachedUser || data,
loading: cachedUser ? false : loading,
error
};
}
Benefits
- Makes code easier to understand by focusing each unit on one purpose that can be described in a single sentence.
- Simplifies testing by isolating functionality so tests can focus on one behavior without complex setup.
- Reduces the risk of unintended side effects when making changes since modifications affect only relevant code.
- Improves code reusability across different contexts since focused units can be imported independently.
- Makes debugging easier by narrowing the search space when issues occur to the specific responsible unit.
- Enables parallel development where multiple developers can work on different responsibilities simultaneously.
- Improves code reviews by making changes easier to evaluate when they affect single, focused concerns.
Tradeoffs
- Can lead to many small files or functions, increasing project complexity and navigation overhead.
- May create indirection that makes following code flow harder when logic is split across multiple modules.
- Requires judgment about what constitutes a “single responsibility” since the right granularity varies by context.
- Can be taken too far, fragmenting cohesive functionality into too many pieces that must be reassembled.
- Creates more abstractions that developers must learn and understand to work with the codebase.
- May result in more boilerplate and ceremony for simple operations that don’t need strict separation.
- Can make finding related code harder when responsibilities are separated into different files or modules.
- Increases the number of units to maintain, test, and document individually.
- May lead to over-engineering simple features that don’t benefit from strict responsibility separation.
- Requires team agreement on responsibility boundaries to avoid inconsistent application across the codebase.