Підхід «розкладемо за технічним типом: /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. Робимо алгоритмічно:
- Створюємо
features/auth/з підпапками - Переносимо файли (через
git mv, щоб зберегти історію) - Створюємо
index.tsз публічним API - Оновлюємо всі імпорти через IDE refactor (Cmd+Shift+R)
- Додаємо ESLint-правило, що забороняє ці файли в старій папці
- Запускаємо тести і typecheck
- 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)
- Рефакторити інкрементально: нове — за новими правилами, старе — мігрувати по фічі за раз
- Бійся прихованих зв’язків між модулями, а не дублювання дрібних речей
Структура проєкту — це не про красу. Це про те, скільки тобі коштує додати нову фічу через рік після старту. І тут різниця між «годину» і «день» вирішується саме на цьому етапі.