Separation of Concerns
Divide code into distinct sections for logic, presentation, and styling to improve maintainability.
In this article
Separation of Concerns
Problem
You’ve probably opened a component file and found 500 lines of spaghetti, with API calls next to inline styles, business logic mixed into the JSX, and validation rules sitting 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 it means mocking half the app just to check that an email field validates correctly.
These files slow the whole team down. Two developers can’t work on the same feature because everything lives in one giant file, and PRs keep running into merge conflicts. Your designer is afraid to tweak the layout because they might break business logic they don’t understand.
Solution
I split code into layers, where each one minds its own business.
API calls go in a service layer that returns clean data. Business rules become pure functions that don’t care where data comes from or how it’s displayed. Components focus on rendering: they receive props, display them, and emit events. Styles live in their own files.
That way, changing how data is fetched leaves your UI alone, changing business rules doesn’t touch your components, and testing validation means calling a function and checking the result, with no React rendering needed.
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 a day 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.isActive && 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 what I’m looking at: it’s handling data, managing business rules, or rendering UI, not all three at once.
- Testing gets simpler: you test validation by calling a function, with no rendering or mocking required.
- You can reuse code. A pricing calculation can be imported anywhere: a React component, a Node script, or a test file.
- Changes stay contained. You can update how data is fetched without touching components, and 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 time spent moving between layers, and for simple features it feels like overkill.
- Teams need to agree on boundaries, or they’ll spend a lot of time arguing 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 leaves tangled code, and too much buries you in abstraction.
- Perfect separation isn’t possible, because UI concerns influence data requirements and the other way around. For prototypes, strict separation slows you down.
- Container components that coordinate everything can grow into small God objects, with maintenance problems of their own.
Summary
Separation of concerns organizes code into layers (data fetching, business logic, presentation, styling), each handling one aspect of your application. When responsibilities are isolated, changes stay contained, testing becomes focused, and teams work in parallel without conflicts. The right amount of separation depends on the project: enough to keep code from getting tangled, but not so much that the abstractions get in the way.