Пошук уроків, статей та іншого контенту
Організуєте файли компонентів, визначите межі відповідальності та налаштуєте зручну структуру папок у проєкті.
У невеликому React-проєкті компоненти часто зберігають в одній папці:
src/
Button.jsx
Header.jsx
UserCard.jsx
Tasks.jsx
App.jsxНа початку це зручно, але з ростом застосунку виникають проблеми:
незрозуміло, де шукати компонент;
складно визначити, хто відповідає за стан;
компоненти починають імпортувати внутрішні деталі одне одного;
зміна однієї функціональності зачіпає багато не пов’язаних файлів;
з’являються великі компоненти, які одночасно працюють із даними, маршрутизацією та розміткою.
Мета структури — не просто розкласти файли по папках. Потрібно визначити:
до якої функціональності належить компонент;
хто володіє станом;
які дані та дії є публічним інтерфейсом;
які залежності дозволені між частинами застосунку;
які компоненти можна повторно використовувати.
Компонент має мати чітку роль. У складному застосунку корисно розділяти такі рівні:
Це загальні візуальні компоненти:
Button;
Input;
Modal;
Spinner;
Card.
Вони не повинні знати про конкретну бізнес-функціональність. Наприклад, Button не має імпортувати модуль замовлень або знати, що таке користувач.
export function Button({ children, type = "button", onClick, disabled }) {
return (
<button type={type} onClick={onClick} disabled={disabled}>
{children}
</button>
);
}Такий компонент отримує поведінку через props, а не через імпорти конкретних модулів.
Вони належать до певної можливості застосунку:
TaskForm;
TaskList;
TaskItem;
OrderSummary;
ProfileForm.
Такі компоненти можуть знати про предметну область, але бажано не передавати їм увесь глобальний стан без потреби.
Сторінка поєднує кілька частин функціональності:
отримує дані;
визначає структуру сторінки;
передає props дочірнім компонентам;
координує взаємодію між функціональними блоками.
Сторінка не обов’язково має містити всю бізнес-логіку. Частину логіки краще винести в хук або окремий модуль.
Компонент верхнього рівня відповідає за композицію застосунку:
маршрути;
глобальні провайдери;
тему;
авторизаційний контекст;
загальний макет.
Він не повинен містити деталі реалізації окремої функціональності.
Для великих React-проєктів зручно групувати файли не лише за типом, а й за функціональністю.
Замість такої структури:
src/
components/
Button.jsx
TaskForm.jsx
TaskList.jsx
UserForm.jsx
hooks/
useTasks.js
useUser.js
services/
tasks.js
users.jsможна використати структуру, орієнтовану на можливості застосунку:
src/
app/
App.jsx
main.jsx
pages/
TasksPage.jsx
features/
tasks/
model/
useTasks.js
ui/
TaskForm.jsx
TaskItem.jsx
TaskList.jsx
shared/
ui/
Button.jsxТут:
app — запуск і композиція застосунку;
pages — сторінки;
features — окремі функціональні можливості;
shared — загальні компоненти та утиліти.
Така організація дозволяє працювати з функціональністю локально. Якщо змінюється робота із завданнями, основні файли знаходяться в features/tasks.
Структура папок має відображати напрямок залежностей.
Зазвичай допустимим є такий напрямок:
app → pages → features → sharedНаприклад:
App може імпортувати сторінку;
сторінка може імпортувати функціональність;
функціональність може імпортувати загальні компоненти;
загальні компоненти не повинні імпортувати сторінки або конкретні функціональності.
Небажана залежність:
shared/ui/Button.jsx → features/tasks/...Якщо загальна кнопка знає про завдання, вона вже не є загальною. Це створює приховане зчеплення і ускладнює повторне використання.
У кожної функціональності можуть бути:
публічні компоненти та функції;
внутрішні модулі, які не повинні використовуватися за межами функціональності.
Наприклад:
features/
tasks/
index.js
model/
useTasks.js
ui/
TaskForm.jsx
TaskItem.jsx
TaskList.jsxФайл index.js може визначати публічний API:
export { useTasks } from "./model/useTasks";
export { TaskForm } from "./ui/TaskForm";
export { TaskList } from "./ui/TaskList";Тоді інші частини застосунку імпортують функціональність через один вхід:
import { TaskForm, TaskList } from "../features/tasks";А не напряму звертаються до кожного внутрішнього файлу:
import { TaskForm } from "../features/tasks/ui/TaskForm";Публічний API корисний, якщо він справді приховує внутрішню структуру. Не потрібно створювати index.js для кожної папки автоматично. Надмірна кількість barrel-файлів може ускладнити пошук джерела імпорту та створити циклічні залежності.
Одна з найважливіших архітектурних меж — місце, де зберігається стан.
Компонент має володіти станом, якщо:
цей стан потрібен лише йому;
стан описує локальну взаємодію, наприклад відкриття меню;
інші компоненти не повинні реагувати на його зміну.
Стан варто підняти вище, якщо:
його використовують кілька сусідніх компонентів;
один компонент змінює стан, а інший його відображає;
потрібно синхронізувати кілька частин інтерфейсу.
Не слід піднімати весь стан до App лише тому, що App є батьківським компонентом. Це збільшує кількість props і робить верхній рівень відповідальним за деталі всіх функціональностей.
Практичний підхід — розділити функціональність на:
model — стан, дії та робота з даними;
ui — компоненти відображення.
Компонент із ui отримує дані та callback-функції через props. Він не повинен знати, звідки вони надійшли.
Наприклад, TaskList може відображати список, але не повинен самостійно вирішувати, як завантажувати завдання. Цю відповідальність можна передати хуку useTasks.
Нижче наведено невеликий застосунок зі списком завдань. Файли можна розмістити у стандартному React-проєкті, створеному за допомогою Vite.
Структура:
src/
app/
App.jsx
main.jsx
pages/
TasksPage.jsx
features/
tasks/
model/
useTasks.js
ui/
TaskForm.jsx
TaskItem.jsx
TaskList.jsx
shared/
ui/
Button.jsxuseTasks.js відповідає за стан списку та операції над ним:
import { useCallback, useState } from "react";
const initialTasks = [
{ id: 1, title: "Спроєктувати структуру компонентів", completed: true },
{ id: 2, title: "Додати тести для форми", completed: false },
];
export function useTasks() {
const [tasks, setTasks] = useState(initialTasks);
const addTask = useCallback((title) => {
const normalizedTitle = title.trim();
if (!normalizedTitle) {
return;
}
setTasks((currentTasks) => [
...currentTasks,
{
id: Date.now(),
title: normalizedTitle,
completed: false,
},
]);
}, []);
const toggleTask = useCallback((taskId) => {
setTasks((currentTasks) =>
currentTasks.map((task) =>
task.id === taskId
? { ...task, completed: !task.completed }
: task
)
);
}, []);
return {
tasks,
addTask,
toggleTask,
};
}Хук не знає, як саме завдання будуть відображені. Він повертає лише дані та операції, які потрібні сторінці.
shared/ui/Button.jsx не залежить від функціональності завдань:
export function Button({
children,
type = "button",
onClick,
disabled = false,
}) {
return (
<button type={type} onClick={onClick} disabled={disabled}>
{children}
</button>
);
}Форма відповідає лише за введення назви та виклик onSubmit:
import { useState } from "react";
import { Button } from "../../../shared/ui/Button";
export function TaskForm({ onSubmit }) {
const [title, setTitle] = useState("");
function handleSubmit(event) {
event.preventDefault();
const normalizedTitle = title.trim();
if (!normalizedTitle) {
return;
}
onSubmit(normalizedTitle);
setTitle("");
}
return (
<form onSubmit={handleSubmit}>
<label>
Нова задача
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Наприклад, перевірити pull request"
/>
</label>
<Button type="submit">Додати</Button>
</form>
);
}Форма не імпортує useTasks. Завдяки цьому її можна використати з іншим джерелом даних або протестувати окремо.
TaskItem.jsx відповідає за один елемент:
export function TaskItem({ task, onToggle }) {
return (
<li>
<label>
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
<span
style={{
textDecoration: task.completed ? "line-through" : "none",
}}
>
{task.title}
</span>
</label>
</li>
);
}TaskList.jsx відповідає за колекцію:
import { TaskItem } from "./TaskItem";
export function TaskList({ tasks, onToggle }) {
if (tasks.length === 0) {
return <p>Завдань поки немає.</p>;
}
return (
<ul>
{tasks.map((task) => (
<TaskItem key={task.id} task={task} onToggle={onToggle} />
))}
</ul>
);
}Компонент списку не змінює масив самостійно. Він викликає callback, а рішення про зміну стану залишається в моделі.
Сторінка поєднує модель і UI:
import { useTasks } from "../features/tasks/model/useTasks";
import { TaskForm } from "../features/tasks/ui/TaskForm";
import { TaskList } from "../features/tasks/ui/TaskList";
export function TasksPage() {
const { tasks, addTask, toggleTask } = useTasks();
return (
<main>
<h1>Мої завдання</h1>
<TaskForm onSubmit={addTask} />
<TaskList tasks={tasks} onToggle={toggleTask} />
</main>
);
}У цій сторінці видно межу відповідальності:
TasksPage координує частини функціональності;
useTasks керує станом;
TaskForm відповідає за введення;
TaskList відповідає за список;
TaskItem відповідає за один рядок;
Button є загальним UI-компонентом.
App.jsx відповідає за композицію:
import { TasksPage } from "../pages/TasksPage";
export function App() {
return <TasksPage />;
}main.jsx монтує React-застосунок:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);Цей приклад можна запустити в React-проєкті Vite. Він не потребує додаткових бібліотек.
Поставте кілька запитань.
Якщо компонент називається TaskItem і відображає властивості завдання, йому місце у features/tasks/ui.
Якщо компонент не знає, що саме він відображає, і лише отримує props, він може належати до shared/ui.
Повторне використання саме по собі не означає, що компонент потрібно одразу перемістити в shared.
Спочатку залиште його в межах функціональності. Переносьте компонент у shared тоді, коли:
його API не містить термінів конкретної предметної області;
він справді потрібен кільком незалежним функціональностям;
його поведінка стабілізувалася.
Передчасне переміщення часто призводить до надто абстрактних компонентів із великою кількістю props.
Якщо компонент лише відображає дані, передавайте йому дані та callback:
<TaskList tasks={tasks} onToggle={toggleTask} />Якщо компонент сам отримує дані, змінює їх, фільтрує та ще й відображає розмітку, це може бути ознакою змішаних відповідальностей.
Великий компонент не обов’язково є проблемою лише через кількість рядків. Важливіше, скільки різних причин може спричинити його зміну.
Компонент варто розділити, якщо він одночасно:
завантажує дані;
керує станом форми;
відображає список;
показує повідомлення про помилки;
містить складні умови відображення;
реалізує кілька незалежних сценаріїв.
Декомпозиція має покращувати межі, а не просто створювати багато маленьких файлів. Наприклад, виділення кожного div в окремий компонент зазвичай не дає користі.
Корисним кандидатом для виділення є блок, який:
має власну відповідальність;
має зрозумілий набір props;
може змінюватися незалежно від батьківського компонента;
має окремий сценарій повторного використання або тестування.
Обидва підходи можуть бути доречними, але вони вирішують різні проблеми.
Групування за типом:
components/
hooks/
services/зручно для невеликого застосунку, де функціональностей мало.
Групування за функціональністю:
features/
auth/
tasks/
notifications/краще масштабується, коли функціональності мають власні компоненти, хуки, запити та моделі.
На практиці можна поєднувати ці підходи:
src/
app/
pages/
features/
auth/
model/
ui/
tasks/
model/
ui/
shared/
ui/
lib/Важливо, щоб назви папок відображали архітектурні межі, а не лише технічний тип файлу.
Під час імпорту дотримуйтеся кількох правил:
імпортуйте через публічний API функціональності, якщо він визначений;
не використовуйте внутрішні модулі іншої функціональності без необхідності;
не дозволяйте shared залежати від features;
не створюйте взаємних імпортів між компонентами;
передавайте поведінку через props або окремі контракти, а не через прихований доступ до стану.
Поганий приклад:
// shared/ui/TaskButton.jsx
import { useTasks } from "../../features/tasks/model/useTasks";
export function TaskButton() {
const { addTask } = useTasks();
return <button onClick={() => addTask("Нове завдання")}>Додати</button>;
}Компонент у shared тепер залежить від завдань і не може бути загальним.
Кращий варіант:
export function ActionButton({ children, onClick }) {
return <button onClick={onClick}>{children}</button>;
}А конкретну дію визначає функціональність або сторінка:
<ActionButton onClick={() => addTask("Нове завдання")}>
Додати
</ActionButton>Не кожен компонент потрібно поміщати в features.
Окрема функціональність виправдана, якщо є завершений сценарій користувача або предметна область:
автентифікація;
створення завдання;
оформлення замовлення;
редагування профілю.
Для простого локального блоку достатньо компонента сторінки або локальної папки.
Надмірне дроблення створює структуру, у якій для зміни одного рядка потрібно переходити через багато абстракцій. Архітектура має зменшувати когнітивне навантаження, а не збільшувати його.
componentsКоли всі компоненти лежать в одному каталозі, їхня належність до функціональності стає неочевидною.
Краще групувати компоненти за функціональністю, а справді загальні елементи залишати в shared/ui.
Компонент, який завантажує дані, зберігає стан, обробляє форму та малює всю сторінку, важко тестувати й змінювати.
Винесіть окремі відповідальності в:
хук або модуль моделі;
компонент форми;
компонент списку;
компонент стану завантаження чи помилки.
sharedКомпонент не стає загальним лише через те, що він лежить у shared.
Якщо UserTaskCard містить логіку конкретної функціональності, його не слід називати загальним. Розміщення має відповідати реальному API та залежностям компонента.
Велика кількість props може означати, що:
компонент поєднує кілька ролей;
частину дочірньої структури потрібно передавати через children;
стан зберігається не на тому рівні;
потрібна окрема функціональність.
Не потрібно автоматично замінювати props глобальним станом. Спочатку перевірте, чи можна спростити межі компонентів.
Якщо сторінки напряму імпортують десятки файлів із внутрішніх папок функціональності, її структура перестає бути прихованою.
Визначте невеликий публічний API та змінюйте внутрішню реалізацію без оновлення всіх споживачів.
Файл index.js у кожній папці не завжди покращує код. Він може:
приховувати реальне джерело експорту;
створювати циклічні імпорти;
ускладнювати аналіз залежностей.
Використовуйте публічні файли входу там, де вони справді формують межу модуля.
Під час створення нової функціональності:
Назвіть сценарій або предметну область.
Створіть для неї окрему папку.
Визначте, який компонент або сторінка координуватиме сценарій.
Винесіть стан і операції над ним у модель або хук.
Розділіть UI-компоненти за відповідальністю.
Винесіть у shared лише компоненти без бізнес-контексту.
Перевірте напрямок імпортів.
Визначте публічні експорти, якщо функціональність використовується з кількох місць.
Переконайтеся, що назви файлів пояснюють їхню роль.
Не створюйте абстракцію до появи реальної потреби в ній.
Структура React-проєкту має відображати межі відповідальності, а не лише типи файлів.
Функціональності зручно групувати в окремих папках.
Компоненти сторінок координують функціональність, але не повинні містити всю її внутрішню логіку.
Стан має знаходитися на найнижчому рівні, який достатній для всіх його споживачів.
UI-компоненти повинні отримувати дані та дії через props і не залежати від конкретної бізнес-функціональності.
Залежності бажано спрямовувати від app і pages до features, а потім до shared.
Публічний API функціональності приховує її внутрішню структуру.
Не варто передчасно створювати загальні компоненти або надмірно дробити код.