Skip to main content
Saved
Pattern
Difficulty Beginner

Single Responsibility

Ensure each component, module, or function has one clear purpose and reason to change.

By Den Odell Added

Single Responsibility

Problem

We’ve all seen the 1,500-line component from hell—it 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 praying you don’t break something.

I call these “kitchen sink” components, and they’re a nightmare to work with. Want to test email validation? You’ll need to mock the API, set up authentication, and render the entire component just to check if ”@” is present.

The real pain comes when 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

Here’s a simple rule I follow: 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 just fetch—not validate or format. A component that renders a form should just render, receiving data through props and emitting events when users interact.

When you follow this rule, changes become surgical: API response changes mean updating one file, validation rule changes mean updating one file, new button styles mean updating one file.

Can you describe each of your functions in a single sentence? “This function validates email addresses.” “This component renders a user avatar.” If your sentences keep requiring “and,” that’s your signal to split things apart.

Example

Here’s a before-and-after showing a component doing too much, then broken into focused pieces.

Before: Multiple Responsibilities

// ❌ Everything crammed into one component
function UserProfile({ userId }) {
  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) 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: open any file and know what it does in seconds.
  • Trivial testing: when a function does one thing, you write one test—no complex setup, no mocking half the universe.
  • Contained changes: update validation rules and everything else stays untouched; side effects stop cascading.
  • 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—sometimes you just want to see everything in one place.
  • Subjective boundaries: deciding what counts as “one responsibility” is genuinely tricky, and reasonable people disagree.
  • Easy to overdo: I’ve seen codebases where a three-line function got its own file—don’t be that person.
  • 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, modifications stay surgical, testing becomes simple, and reuse actually works. The challenge is recognizing when something is doing too much—and splitting it before the complexity compounds.

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.