- Published on
Багатошаровий дизайн у React: від інтерфейсу до даних
- Автори
- Name
- Еклезіаст
- github
Масштабований React-застосунок зручно будувати як набір шарів з передбачуваними ролями та межами. Мета — розділення відповідальностей (separation of concerns): кожен фрагмент коду має одну причину для змін, а залежності спрямовані «вниз» — від інтерфейсу до джерел даних.
Такий підхід добре узгоджується з ідеями MVVM: view збирається з представлень (компонентів) і моделей представлення (кастомних хуків), а дані проходять через шар доступу до даних.
Шар UI → Views (компоненти) + View Models (кастомні хуки)
Шар домену → Use-cases (за потреби, для складної логіки)
Шар даних → Репозиторії + сервіси
Нижче — практичні правила та приклади.
Шар інтерфейсу (UI)
Views — React-компоненти
View — це переважно презентаційний компонент. Він відображає UI за отриманими даними й передає дії користувача вгору (через колбеки). У view не повинно бути бізнес-логіки: ні розрахунків, ні правил доступу — лише те, що безпосередньо стосується відображення та подій.
Допустима логіка у view:
- умовний рендер за прапорцями з моделі представлення;
- анімації та верстка;
- проста навігація (наприклад,
useNavigate).
// views/UserProfileView.tsx
interface Props {
user: UserProfile | null
isLoading: boolean
onLogout: () => void
}
export const UserProfileView = ({ user, isLoading, onLogout }: Props) => {
if (isLoading) return <Spinner />
if (!user) return <EmptyState />
return (
<section>
<h1>{user.fullName}</h1>
<p>{user.email}</p>
<button onClick={onLogout}>Вийти</button>
</section>
)
}
View Models — кастомні хуки
Модель представлення — це кастомний хук, який:
- збирає дані з репозиторіїв;
- перетворює їх у зручний для UI вигляд;
- тримає локальний стан інтерфейсу за потреби;
- експонує команди (колбеки) для дій користувача.
Звичайно дотримуються співвідношення один view або фіча — один такий хук, щоб не розмазувати оркестрацію по кількох місцях.
// view-models/useUserProfileViewModel.ts
export const useUserProfileViewModel = () => {
const { data: user, isLoading } = useUserRepository()
const { mutate: logout } = useLogoutCommand()
const userProfile = user
? {
fullName: `${user.firstName} ${user.lastName}`,
email: user.email,
avatarUrl: user.avatar ?? DEFAULT_AVATAR,
}
: null
return {
user: userProfile,
isLoading,
onLogout: logout,
}
}
// Зв’язування view і view model
export const UserProfilePage = () => {
const props = useUserProfileViewModel()
return <UserProfileView {...props} />
}
Шар даних
Сервіси — базовий клієнт до API
Сервіси — найнижчий рівень абстракції над зовнішнім світом. Вони обгортають REST, WebSocket, localStorage, браузерні API тощо й повертають базовий результат асинхронних викликів. У сервісі немає стану застосунку й немає знання про доменні правила — лише транспорт і формат відповіді.
// services/userService.ts
export const userService = {
getMe: (): Promise<RawUserDTO> => fetch('/api/users/me').then((r) => r.json()),
logout: (): Promise<void> => fetch('/api/auth/logout', { method: 'POST' }).then(() => undefined),
}
Репозиторії — джерело істини для доменних даних
Репозиторій розташований над сервісом. Він:
- викликає один або кілька сервісів;
- перетворює DTO на доменні моделі;
- зосереджує кешування, обробку помилок, повтори запитів, polling;
- часто експонує дані через React Query, Zustand тощо.
// repositories/useUserRepository.ts
export const useUserRepository = () => {
return useQuery({
queryKey: ['user', 'me'],
queryFn: async (): Promise<UserProfile> => {
const dto = await userService.getMe()
return {
id: dto.id,
firstName: dto.first_name,
lastName: dto.last_name,
email: dto.email,
avatar: dto.profile_picture_url ?? null,
}
},
staleTime: 5 * 60 * 1000,
retry: 2,
})
}
Між репозиторіями та моделями представлення — зв’язок багато-до-багатьох: одна модель може читати кілька репозиторіїв, один репозиторій можуть використовувати різні екрани.
Правило: репозиторії не знають один про одного. Якщо потрібно об’єднати дані з кількох джерел — робіть це в моделі представлення або в окремому use-case (див. нижче).
За потреби: доменний шар — use-cases
Коли проєкт росте, у моделях представлення накопичується логіка, яка:
- зливає дані з кількох репозиторіїв;
- стає складною для читання та тестів;
- повторюється на різних екранах.
Таку логіку варто виносити в хуки use-case:
// use-cases/useOrderSummaryUseCase.ts
export const useOrderSummaryUseCase = () => {
const { data: cart } = useCartRepository()
const { data: user } = useUserRepository()
const { data: discounts } = useDiscountRepository()
const summary = useMemo(() => {
if (!cart || !user || !discounts) return null
const applicableDiscount = discounts.find((d) => d.eligibleTiers.includes(user.membershipTier))
return {
subtotal: cart.total,
discount: applicableDiscount?.amount ?? 0,
finalTotal: cart.total - (applicableDiscount?.amount ?? 0),
itemCount: cart.items.length,
}
}, [cart, user, discounts])
return { summary, isLoading: !cart || !user || !discounts }
}
| Переваги | Недоліки |
|---|---|
| Менше дублювання між view models | Більше файлів і когнітивного навантаження |
| Простіше ізольовано тестувати складну логіку | Потрібні додаткові моки в тестах |
| Моделі представлення лишаються тонкими | Ризик надмірної інженерії для простих фіч |
Орієнтир: додавайте use-cases, коли з’являється реальна потреба. Почніть із репозиторіїв усередині моделі представлення й виносьте use-case, коли побачите повторювані або громіздкі шматки логіки.
Повний потік
UserProfilePage
└─ useUserProfileViewModel() ← View Model
├─ useUserRepository() ← Репозиторій (React Query)
│ └─ userService.getMe() ← Сервіс (fetch)
└─ useLogoutCommand() ← Мутація через репозиторій
└─ userService.logout() ← Сервіс (fetch)
Структура каталогів (приклад)
src/
├── views/ # Презентаційні компоненти (без бізнес-логіки)
│ └── UserProfileView.tsx
├── view-models/ # Кастомні хуки (зазвичай один на екран/фічу)
│ └── useUserProfileViewModel.ts
├── repositories/ # Доступ до даних і мапінг DTO → домен
│ └── useUserRepository.ts
├── services/ # Тонкі клієнти до API (fetch, axios…)
│ └── userService.ts
├── use-cases/ # За потреби: складна крос-репозиторна логіка
│ └── useOrderSummaryUseCase.ts
└── models/ # Типи доменних моделей (TypeScript)
└── UserProfile.ts
Назви папок можна адаптувати під команду; важливіше дотримуватися напрямку залежностей і ролей, ніж буквальне копіювання дерев.
Ключові правила
- Views викликають лише хуки моделей представлення — не репозиторії й не сервіси напряму.
- Моделі представлення складаються з репозиторіїв — не викликають сервіси напряму (транспорт лишається в репозиторіях).
- Репозиторії викликають сервіси і відповідають за перетворення DTO у доменну модель.
- Сервіси без стану застосунку — без кешу бізнес-даних у глобальному сенсі, лише асинхронний I/O.
- Репозиторії не знають про інші репозиторії — перетин даних — у use-case або в моделі представлення.
Універсальна мета цієї архітектури — передбачуваність змін і зрозумілі межі між UI, правилами та даними. Вона забезпечує більш чіткий розподіл відповідальностей і значно покращує тестованість на кожному рівні. Це робить систему більш гнучкою, надійною та придатною до масштабування.