A good way to explain Separation of Concerns is to split a feature into four distinct responsibilities:
| Layer | Responsibility |
|---|---|
| UI | How things look |
| State | What data is currently stored |
| Logic | Business rules and calculations |
| Data | Fetching and persisting data |
Let's use a simple User Profile feature.
Data Layer
Responsible for communicating with APIs, databases, local storage, etc.
File: services/userService.ts
export interface User {
id: number
name: string
username: string
email: string
phone: string
website: string
address: {
street: string
suite: string
city: string
zipcode: string
geo: {
lat: string
lng: string
}
}
company: {
name: string
catchPhrase: string
bs: string
}
}
export const getUser = async (
id: number
): Promise<User> => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${id}`
)
return response.json()
}
- No React
- No state
- No UI
- No business logic
Only data access.
Logic Layer
Contains business rules.
File: lib/userLogic.ts
import type { User } from '../services/userService'
export const getFullAddress = (
user: User
): string => {
const { address } = user
return `${address.street}, ${address.suite}, ${address.city}`
}
export const getMapLocation = (
user: User
): string => {
return `${user.address.geo.lat}, ${user.address.geo.lng}`
}
export const getCompanySummary = (
user: User
): string => {
return `${user.company.name} - ${user.company.catchPhrase}`
}
export const hasBusinessWebsite = (
user: User
): boolean => {
return user.website.length > 0
}
- No React
- No API calls
- No UI
Only business rules.
State Layer
Owns application state.
File: context/UserContext.tsx
import {
createContext,
useContext,
useEffect,
useState,
} from 'react'
import {
User,
getUser,
} from '../services/userService'
interface UserContextType {
user: User | null
}
const UserContext =
createContext<UserContextType | null>(null)
export const UserProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const [user, setUser] =
useState<User | null>(null)
useEffect(() => {
getUser(1).then(setUser)
}, [])
return (
<UserContext.Provider
value={{ user }}
>
{children}
</UserContext.Provider>
)
}
export const useUser = () => {
const context = useContext(UserContext)
if (!context) {
throw new Error('Missing provider')
}
return context
}
Notice:
- Stores state
- Loads data
- No UI
- No business rules
UI Layer
Responsible for presentation.
File: components/UserProfile.tsx
import { useUser } from '../context/UserContext'
import { getGreeting, isAdult } from '../lib/userLogic'
const UserProfile = () => {
const { user } = useUser()
if (!user) {
return <p>Loading...</p>
}
return (
<div>
<h1>
{getGreeting(user)}
</h1>
<p>
Name: {user.name}
</p>
<p>
Age: {user.age}
</p>
<p>
Adult:
{isAdult(user)
? ' Yes'
: ' No'}
</p>
</div>
)
}
export default UserProfile
- No fetch()
- No state ownership
- No business rules
Architecture Overview
┌─────────────────┐
│ UI │
│ UserProfile │
└────────┬────────┘
│
▼
┌─────────────────┐
│ State │
│ UserContext │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Data │
│ userService │
└─────────────────┘
UI
│
▼
┌─────────────────┐
│ Logic │
│ userLogic │
└─────────────────┘
For larger React applications this separation scales extremely well:
atoms/
molecules/
organisms/
context/
state/
services/
repositories/
lib/
domain/
rules/
The key rule is:
UI displays data, State manages data, Logic decides what should happen, and Data retrieves or persists information.
When those concerns stay separated, applications become easier to test, maintain, and scale across multiple teams.