Notatky logo
Published on

Багатошаровий дизайн у React: від інтерфейсу до даних

Автори
  • avatar
    Name
    Еклезіаст
    github

Масштабований React-застосунок зручно будувати як набір шарів з передбачуваними ролями та межами. Мета — розділення відповідальностей (separation of concerns): кожен фрагмент коду має одну причину для змін, а залежності спрямовані «вниз» — від інтерфейсу до джерел даних.

Такий підхід добре узгоджується з ідеями MVVM: view збирається з представлень (компонентів) і моделей представлення (кастомних хуків), а дані проходять через шар доступу до даних.

Шар UIViews (компоненти) + 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

Назви папок можна адаптувати під команду; важливіше дотримуватися напрямку залежностей і ролей, ніж буквальне копіювання дерев.

Ключові правила

  1. Views викликають лише хуки моделей представлення — не репозиторії й не сервіси напряму.
  2. Моделі представлення складаються з репозиторіїв — не викликають сервіси напряму (транспорт лишається в репозиторіях).
  3. Репозиторії викликають сервіси і відповідають за перетворення DTO у доменну модель.
  4. Сервіси без стану застосунку — без кешу бізнес-даних у глобальному сенсі, лише асинхронний I/O.
  5. Репозиторії не знають про інші репозиторії — перетин даних — у use-case або в моделі представлення.

Універсальна мета цієї архітектури — передбачуваність змін і зрозумілі межі між UI, правилами та даними. Вона забезпечує більш чіткий розподіл відповідальностей і значно покращує тестованість на кожному рівні. Це робить систему більш гнучкою, надійною та придатною до масштабування.