Separation of Concerns
Problem
You’ve probably opened a component file and felt your heart sink. There it is: 500 lines of tangled spaghetti where API calls sit next to inline styles, business logic weaves through JSX, and validation rules hide between event handlers.
I’ve seen this pattern too many times. You want to reuse validation logic in another component, but it’s so intertwined with UI code that you copy-paste the whole thing. Testing? You’d need to mock the entire universe just to verify an email field validates correctly.
These files become team bottlenecks. Two developers can’t work on the same feature because everything lives in one giant file. Every PR becomes a merge conflict. Your designer is afraid to tweak the layout because they might break business logic they don’t understand.
Solution
Here’s my advice: divide your code into layers where each one minds its own business.
Put API calls in a service layer that returns clean data. Extract business rules into pure functions that don’t care where data comes from or how it’s displayed. Keep components focused on rendering—they receive props, display them, and emit events. Keep styles in their own files.
The goal: when you change how data is fetched, your UI stays untouched; when business rules change, your components don’t care; when you want to test validation, you call a function and check the result—no React rendering required.
This sounds like more work upfront, and it is. But the first time you swap REST for GraphQL and only touch one file, or a new developer understands your codebase in an afternoon instead of a week, you’ll see why it’s worth it.
Example
Here’s a messy component broken apart into focused layers.
Basic Layer Separation
// ❌ MIXED: Everything in one place
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
if (data.age >= 18) setUser(data);
});
}, [userId]);
return (
<div style={{ padding: '20px', border: '1px solid #ccc' }}>
{user && <h1>{user.name}</h1>}
</div>
);
}
// ✅ SEPARATED: Each layer has one job
const userApi = {
fetchUser: (id) => fetch(`/api/users/${id}`).then(r => r.json())
};
const userValidation = {
canViewProfile: (user) => user.isActive && user.age >= 18
};
function UserProfile({ user }) {
return (
<div className="user-profile">
<h1>{user.name}</h1>
</div>
);
}
function UserProfileContainer({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
userApi.fetchUser(userId).then(data => {
if (userValidation.canViewProfile(data)) setUser(data);
});
}, [userId]);
if (!user) return <div>Loading...</div>;
return <UserProfile user={user} />;
}
Separating Data Fetching from Presentation
// Data layer - transforms API responses to domain models
class UserService {
async getUser(id) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return { id: data.user_id, name: data.full_name, email: data.email_address };
}
async updateUser(id, updates) {
return fetch(`/api/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
}
}
// Business logic - framework-agnostic validation
class UserValidator {
static validate(user) {
const errors = {};
if (!user.name || user.name.length < 2) errors.name = 'Name must be at least 2 characters';
if (!user.email?.includes('@')) errors.email = 'Valid email required';
return { isValid: Object.keys(errors).length === 0, errors };
}
}
// Presentation - pure UI, no business logic
function UserForm({ user, errors, onSubmit, onChange }) {
return (
<form onSubmit={onSubmit}>
<input value={user.name} onChange={e => onChange('name', e.target.value)} />
{errors.name && <span className="error">{errors.name}</span>}
<input value={user.email} onChange={e => onChange('email', e.target.value)} />
{errors.email && <span className="error">{errors.email}</span>}
<button type="submit">Save</button>
</form>
);
}
// Container - orchestrates layers
function UserFormContainer({ userId }) {
const [user, setUser] = useState({ name: '', email: '' });
const [errors, setErrors] = useState({});
useEffect(() => { new UserService().getUser(userId).then(setUser); }, [userId]);
const handleSubmit = async (e) => {
e.preventDefault();
const validation = UserValidator.validate(user);
if (!validation.isValid) { setErrors(validation.errors); return; }
await new UserService().updateUser(userId, user);
};
return <UserForm user={user} errors={errors} onSubmit={handleSubmit}
onChange={(field, value) => setUser(prev => ({ ...prev, [field]: value }))} />;
}
Separating Business Logic from Framework Code
// ❌ MIXED: Business logic coupled to React hooks
function useShoppingCart() {
const [items, setItems] = useState([]);
const addItem = (product) => {
setItems(prev => {
const existing = prev.find(item => item.id === product.id);
if (existing) {
return prev.map(item => item.id === product.id
? { ...item, quantity: item.quantity + 1 } : item);
}
return [...prev, { ...product, quantity: 1 }];
});
};
return { items, addItem };
}
// ✅ SEPARATED: Pure domain logic, thin React adapter
class Cart {
constructor(items = []) { this.items = items; }
addItem(product) {
const existing = this.items.find(item => item.id === product.id);
if (existing) {
return new Cart(this.items.map(item => item.id === product.id
? { ...item, quantity: item.quantity + 1 } : item));
}
return new Cart([...this.items, { ...product, quantity: 1 }]);
}
getTotal() {
return this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
}
}
// Thin adapter wraps domain logic
function useShoppingCart() {
const [cart, setCart] = useState(() => new Cart());
return {
items: cart.items,
total: cart.getTotal(),
addItem: (product) => setCart(prev => prev.addItem(product))
};
}
Separating Styles from Component Logic
// ❌ MIXED: Styles embedded in component
function Button({ onClick, children }) {
return (
<button onClick={onClick}
style={{ padding: '10px 20px', backgroundColor: '#007bff', color: 'white' }}>
{children}
</button>
);
}
// ✅ SEPARATED: CSS module handles styling
import styles from './Button.module.css';
function Button({ onClick, children }) {
return <button onClick={onClick} className={styles.button}>{children}</button>;
}
/* Button.module.css */
.button { padding: 10px 20px; background-color: #007bff; color: white; }
.button:hover { background-color: #0056b3; }
Benefits
- When I open a file, I know exactly what I’m looking at—it’s handling data, managing business rules, or rendering UI, not all three at once.
- Testing gets simpler: test validation by calling a function, no rendering or mocking required.
- You can actually reuse code. That pricing calculation? Import it anywhere—React component, Node script, or test file.
- Changes stop cascading. Update how data is fetched without touching components; change styling without affecting business logic.
- Teams work in parallel. One developer handles API integration while another builds the UI, and code reviews stay focused on one concern at a time.
Tradeoffs
- I won’t pretend this is free. More files means more cognitive overhead navigating between layers, and for simple features it feels like overkill.
- Teams need to agree on boundaries—without clear guidelines, you’ll debate endlessly about what belongs where.
- There’s real tension with colocation. Sometimes keeping related code together is clearer than spreading it across files for purity’s sake.
- Debugging gets trickier when bugs span multiple layers. Finding the right level of separation is hard—too little creates tangles, too much drowns you in abstraction.
- Perfect separation is a myth; UI concerns influence data requirements and vice versa. For prototypes, strict separation just slows you down.
- Container components that coordinate everything can become mini God objects—their own maintenance headache.
Summary
Separation of concerns organizes code into layers—data fetching, business logic, presentation, styling—where each handles one aspect of your application. When responsibilities are isolated, changes stay contained, testing becomes focused, and teams work in parallel without conflicts. Find the right level for your project: enough to prevent tangles, not so much that you drown in abstraction.