React / Vue: Structure
Glossary overview

React / Vue: Structure

Підхід «розкладемо за технічним типом: /components, /hooks, /store» виглядає логічно перші три місяці, а потім перетворюється на болото, де видалення однієї фічі — це квест по чотирьох папках.

Розберу детально, чому так стається у React і Vue проєктах, як виглядає альтернатива, і де у неї є реальні підводні камені.

Чому групування за технічним типом ламається на масштабі

Типова структура React-проєкту, який почали «як годиться»:

src/
├── components/
│   ├── UserCard.tsx
│   ├── UserList.tsx
│   ├── ProductCard.tsx
│   ├── ProductFilter.tsx
│   ├── CartItem.tsx
│   ├── CartSummary.tsx
│   └── CheckoutForm.tsx
├── hooks/
│   ├── useUser.ts
│   ├── useProducts.ts
│   └── useCart.ts
├── store/
│   ├── userSlice.ts
│   ├── productsSlice.ts
│   └── cartSlice.ts
└── api/
    ├── userApi.ts
    ├── productsApi.ts
    └── cartApi.ts

Тепер уявіть задачу: «видали функціонал кошика». Що треба зробити?

  • Зайти в components/ і знайти всі файли, що стосуються кошика (CartItem, CartSummary, CheckoutForm — а CheckoutForm це кошик чи окрема фіча?)
  • Зайти в hooks/ і знайти useCart
  • Зайти в store/ і прибрати cartSlice
  • Зайти в api/ і прибрати cartApi
  • Перевірити, чи ніде в інших фічах не імпортується щось з цих файлів
  • Молитись, що тести пройдуть

А якщо проєкту рік і там 200 компонентів — це вже не задача, а археологія. Ось чому така структура — антипатерн для всього, що більше за лендінг.

Що саме тут зламано (з точки зору архітектури)

  • Низька когезія. Файли, які змінюються разом, лежать у різних папках. Зміна одного бізнес-сценарію торкається 4 каталогів.
  • Високий зв’язок (coupling). Немає меж між модулями — будь-хто може імпортити будь-що звідусіль.
  • Втрата контексту. Дивлячись на UserCard.tsx, ти не бачиш, які хуки/стори/API з ним пов’язані. Треба тримати все в голові.
  • Когнітивне навантаження на навігацію. Замість думати про бізнес-логіку, ти думаєш «де ж я залишив цей файл».

Альтернатива: групування за фічами

Є три поняття, які говорять про одне з різних кутів:

  • High Cohesion — це базовий принцип («тримай пов’язане разом»)
  • DDD — це філософія, яка каже, що «пов’язане» визначається бізнес-доменом, а не технічним типом коду
  • FSD — це конкретна методологія для фронтенду, яка реалізує перші два через структуру папок і правила імпортів

Є два рівні зрілості цього підходу — простий feature-based і повноцінний Feature-Sliced Design. Розгляну обидва.

Рівень 1: Feature-based (для середніх проєктів)

src/
├── features/
│   ├── user/
│   │   ├── components/
│   │   │   ├── UserCard.tsx
│   │   │   └── UserList.tsx
│   │   ├── hooks/
│   │   │   └── useUser.ts
│   │   ├── api.ts
│   │   ├── store.ts
│   │   ├── types.ts
│   │   └── index.ts        ← публічний API фічі
│   ├── products/
│   │   └── ...
│   └── cart/
│       └── ...
├── shared/                   ← реально перевикористовувані штуки
│   ├── ui/                   (Button, Input, Modal)
│   ├── lib/                  (utils, helpers)
│   └── api/                  (axios instance, interceptors)
└── app/                      ← роутинг, провайдери, layout

Ключова деталь — index.ts у кожній фічі. Це публічний API модуля. Зовні імпортуєш тільки те, що фіча сама експортує:

// features/cart/index.ts
export { CartItem } from './components/CartItem';
export { CartSummary } from './components/CartSummary';
export { useCart } from './hooks/useCart';
export type { Cart, CartItem as CartItemType } from './types';
// Усе інше — внутрішнє, ззовні недоступне
// ✅ Правильно
import { CartItem, useCart } from '@/features/cart';

// ❌ Неправильно — ліземо у внутрянку
import { CartItem } from '@/features/cart/components/CartItem';
import { cartReducer } from '@/features/cart/store';

Це лінтиться через eslint-plugin-boundaries або eslint-plugin-import з правилом no-restricted-paths. Без лінтера дисципліна тримається тиждень, далі деградує.

Рівень 2: Feature-Sliced Design (FSD)(для великих проєктів)

FSD — методологія, що розвинулась у російськомовному фронтенд-ком’юніті і стала фактичним стандартом для серйозних React/Vue проєктів. Її головна ідея — ввести обов’язкову ієрархію шарів з односторонніми залежностями.

src/
├── app/          ← ініціалізація, провайдери, глобальні стилі, роутер
├── pages/        ← сторінки (тонкі композиції з widgets/features)
├── widgets/      ← великі самодостатні блоки (Header, Sidebar, ProductTable)
├── features/     ← інтерактивні фічі (AddToCart, LoginForm, FilterProducts)
├── entities/     ← бізнес-сутності (User, Product, Order)
└── shared/       ← UI-кіт, утиліти, конфіги, API-клієнт

Правило імпортів — строго зверху вниз. pages може імпортити widgets, widgets може імпортити features і entities, але ніколи навпаки. entities не знає про features. shared не знає ні про кого.

Кожен слайс всередині шару має однакову внутрішню структуру (segments):

features/add-to-cart/
├── ui/           ← React/Vue компоненти
├── model/        ← стейт, хуки, бізнес-логіка
├── api/          ← запити до сервера
├── lib/          ← хелпери цієї фічі
└── index.ts      ← public API

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

React: повний приклад фічі

Ось як виглядає фіча add-to-cart у FSD-стилі з реальним кодом.

// features/add-to-cart/model/useAddToCart.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addToCartRequest } from '../api/addToCartRequest';

export const useAddToCart = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: addToCartRequest,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['cart'] });
    },
  });
};
// features/add-to-cart/api/addToCartRequest.ts
import { apiClient } from '@/shared/api';

export const addToCartRequest = async (productId: string) => {
  const { data } = await apiClient.post('/cart/items', { productId });
  return data;
};
// features/add-to-cart/ui/AddToCartButton.tsx
import { Button } from '@/shared/ui';
import { useAddToCart } from '../model/useAddToCart';

type Props = {
  productId: string;
};

export const AddToCartButton = ({ productId }: Props) => {
  const { mutate, isPending } = useAddToCart();

  return (
    <Button
      onClick={() => mutate(productId)}
      disabled={isPending}
    >
      {isPending ? 'Додаємо...' : 'У кошик'}
    </Button>
  );
};
// features/add-to-cart/index.ts
export { AddToCartButton } from './ui/AddToCartButton';
export { useAddToCart } from './model/useAddToCart';

Тепер на сторінці продукту:

// pages/product/ProductPage.tsx
import { ProductCard } from '@/entities/product';
import { AddToCartButton } from '@/features/add-to-cart';
import { ToggleFavorite } from '@/features/toggle-favorite';

export const ProductPage = ({ productId }: { productId: string }) => {
  return (
    <div>
      <ProductCard productId={productId} />
      <AddToCartButton productId={productId} />
      <ToggleFavorite productId={productId} />
    </div>
  );
};

Сторінка стає тонким композером. Видалити фічу «улюблене» — означає видалити одну папку features/toggle-favorite/ і прибрати один імпорт. Все.

Vue: те саме, але з композаблами і Pinia

У Vue логіка повністю аналогічна, але з особливостями реактивної моделі. Composables замість hooks, Pinia stores замість Redux slices, але принцип розкладки той самий.

// features/add-to-cart/model/cart.store.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { CartItem } from './types';

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([]);

  const totalCount = computed(() =>
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  );

  const addItem = (productId: string) => {
    const existing = items.value.find(i => i.productId === productId);
    if (existing) {
      existing.quantity += 1;
    } else {
      items.value.push({ productId, quantity: 1 });
    }
  };

  return { items, totalCount, addItem };
});
// features/add-to-cart/model/useAddToCart.ts
import { ref } from 'vue';
import { useCartStore } from './cart.store';
import { addToCartRequest } from '../api/addToCartRequest';

export const useAddToCart = () => {
  const cartStore = useCartStore();
  const isPending = ref(false);

  const addToCart = async (productId: string) => {
    isPending.value = true;
    try {
      await addToCartRequest(productId);
      cartStore.addItem(productId);
    } finally {
      isPending.value = false;
    }
  };

  return { addToCart, isPending };
};
<!-- features/add-to-cart/ui/AddToCartButton.vue -->
<script setup lang="ts">
import { BaseButton } from '@/shared/ui';
import { useAddToCart } from '../model/useAddToCart';

const props = defineProps<{ productId: string }>();
const { addToCart, isPending } = useAddToCart();
</script>

<template>
  <BaseButton
    :disabled="isPending"
    @click="addToCart(props.productId)"
  >
    {{ isPending ? 'Додаємо...' : 'У кошик' }}
  </BaseButton>
</template>
// features/add-to-cart/index.ts
export { default as AddToCartButton } from './ui/AddToCartButton.vue';
export { useAddToCart } from './model/useAddToCart';
export { useCartStore } from './model/cart.store';

У Vue є одна важлива річ, яку часто роблять неправильно: розкидують Pinia stores в окрему глобальну папку /stores. Це повторює ту саму помилку, що і з /components у React. Store фічі має жити всередині фічі, у її model/ сегменті.

Рефакторинг: як перейти зі старої структури

Найбільша помилка — намагатись зробити «велике переписування» за один раз. Це провалюється завжди. Ось практичний інкрементальний підхід.

Крок 1: створити нову структуру поряд зі старою

src/
├── components/        ← стара структура, не чіпаємо
├── hooks/
├── store/
├── features/          ← нова — сюди переїжджаємо поступово
├── entities/
└── shared/

Крок 2: правило «нове — у новому»

Будь-яка нова фіча пишеться вже за FSD. Старе чіпаємо тільки коли треба його змінити в межах задачі.

Крок 3: рефакторити по одній фічі за раз

Беремо одну логічну фічу (наприклад, авторизація) і переносимо її повністю — компоненти, хуки, стор, API. Робимо алгоритмічно:

  1. Створюємо features/auth/ з підпапками
  2. Переносимо файли (через git mv, щоб зберегти історію)
  3. Створюємо index.ts з публічним API
  4. Оновлюємо всі імпорти через IDE refactor (Cmd+Shift+R)
  5. Додаємо ESLint-правило, що забороняє ці файли в старій папці
  6. Запускаємо тести і typecheck
  7. PR — рев’ю — мердж

Крок 4: налаштувати лінтер

// .eslintrc.js
module.exports = {
  rules: {
    'import/no-restricted-paths': ['error', {
      zones: [
        // shared не може імпортити нічого з верхніх шарів
        {
          target: './src/shared',
          from: ['./src/entities', './src/features', './src/widgets', './src/pages'],
        },
        // entities не може імпортити features/widgets/pages
        {
          target: './src/entities',
          from: ['./src/features', './src/widgets', './src/pages'],
        },
        // features не можуть імпортити одна одну напряму
        {
          target: './src/features/*',
          from: './src/features/*',
          except: ['./'],
        },
      ],
    }],
  },
};

Або готовий @feature-sliced/eslint-config, який містить весь набір правил FSD з коробки.

Підводні камені, які вилазять на 3-4 місяці використання

1. «А де це класти — у feature чи entity?»

Найчастіше питання. Просте правило:

  • Entity — це дані і їх відображення: UserAvatar, ProductCard з картинкою і ціною, OrderStatus. Без інтерактивних дій.
  • Feature — це дія: EditUserProfile, AddToCart, ChangeOrderStatus. Завжди змінює щось у системі.

Якщо сумніваєшся — клади в feature. Entity має бути «німою» і використовуватися фічами.

2. Cross-feature імпорти — спокуса

Рано чи пізно з’явиться задача типу «у фічі checkout треба показати кнопку add-to-cart». Спокуса — імпортнути з features/add-to-cart у features/checkout. Не роби так.

Дві фічі не повинні знати одна про одну. Композиція робиться на рівні вище — у widget або page. Якщо обидві потрібні разом — створюй widget, який їх обидва використовує:

// widgets/product-actions/ProductActions.tsx
import { AddToCartButton } from '@/features/add-to-cart';
import { ToggleFavorite } from '@/features/toggle-favorite';

export const ProductActions = ({ productId }: Props) => (
  <div className="product-actions">
    <AddToCartButton productId={productId} />
    <ToggleFavorite productId={productId} />
  </div>
);

3. Спільні типи між фічами

Тип User потрібен у 5 різних фічах. Куди його класти?

В entities/user/model/types.ts. Entity — це місце, де живе «канонічне» визначення бізнес-сутності. Фічі імпортять import type { User } from '@/entities/user'.

4. Дублювання vs передчасна абстракція

Головна теза, яку часто розуміють неправильно:

Не бійся дублювання дрібних речей. Бійся незрозумілих зв’язків між модулями.

Якщо у двох фічах є схожа функція formatPrice на 5 рядків — краще задублювати, ніж тягнути її в shared/. Дублювання локальне і дешеве; тягнути ниточку в shared при кожній схожості — це дорогий зв’язок назавжди.

У shared/ має потрапляти тільки те, що:

  • Реально використовується в 3+ місцях
  • Не має бізнес-сенсу (Button, debounce, axios instance, форматер дат)
  • Стабільне (не буде змінюватись разом з фічею)

5. Page-specific компоненти

Часто є компоненти, які потрібні тільки на одній сторінці. Куди їх? Не у feature і не у widget, бо вони не перевикористовуються.

Відповідь: у pages/your-page/ui/. Сама сторінка — це теж слайс зі своєю внутрішньою структурою, і дрібні компоненти, специфічні саме для неї, можуть жити прямо там.

6. FSD може бути overkill

Чесна частина. FSD починає окуповуватись, коли:

  • Проєкт живе понад рік
  • Команда від 3 розробників
  • Є чіткі бізнес-домени (e-commerce, CRM, dashboard)
  • Часто додаються або видаляються фічі

Для лендінга, MVP, прототипу або проєкту з 20 компонентами — FSD створює більше церемонії, ніж приносить користі. Плоский /components там цілком ок.

Підсумок

  • /components, /hooks, /store — це структура за технічним типом, яка перестає масштабуватись після 30-50 компонентів
  • Альтернатива — групування за фічами: код, що змінюється разом, лежить разом
  • Для середніх проєктів вистачає простого feature-based з public API через index.ts
  • Для великих проєктів — Feature-Sliced Design з ієрархією шарів і ESLint-правилами
  • У React і Vue принципи однакові, відрізняються тільки інструменти (hooks/composables, Redux/Pinia)
  • Рефакторити інкрементально: нове — за новими правилами, старе — мігрувати по фічі за раз
  • Бійся прихованих зв’язків між модулями, а не дублювання дрібних речей

Структура проєкту — це не про красу. Це про те, скільки тобі коштує додати нову фічу через рік після старту. І тут різниця між «годину» і «день» вирішується саме на цьому етапі.