Skip to main content

A good way to explain Separation of Concerns is to split a feature into four distinct responsibilities:

LayerResponsibility
UIHow things look
StateWhat data is currently stored
LogicBusiness rules and calculations
DataFetching 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.