Separation of Concerns
Divide code into distinct sections for data, logic, presentation, and styling to improve maintainability.
In this article
Problem
You’ve probably opened a component file and found 500 lines of spaghetti, with API calls next to inline styles and business logic mixed into the JSX.
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 your whole team down. Two developers can’t work on the same feature because everything is 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 and emit events. Styles go in their own files.
That way, changing how data is fetched leaves your UI alone, 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 understand 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, because it handles one concern instead of mixing them all together.
- 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, from a React component to a Node script.
- 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 there’s no downside. More files means more time spent moving between layers, and for simple features it feels like overkill.
- Your team needs to agree on boundaries, or you’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 for data fetching, business logic, presentation, and styling, with each layer handling one aspect of your application. When responsibilities are isolated, changes stay contained and teams can work in parallel without conflicts. The right amount of separation depends on your project: enough to keep code from getting tangled, but not so much that the abstractions get in the way.