React не навязывает никакой структуры — это его сила и одновременно ловушка. Пустой проект можно организовать как угодно, и пока файлов мало, любой вариант работает. Но когда экранов становится десять, а команда — трое, отсутствие дисциплины даёт о себе знать: никто не знает, где лежит нужный файл, правка одной кнопки затрагивает код в пяти местах.
Эта статья — о том, как избежать такого исхода с самого начала.
Раскладка по типам — почему это плохо
Первый инстинкт — разложить файлы по тому, что они из себя представляют: все компоненты в components/, все хуки в hooks/, все утилиты в utils/. Выглядит аккуратно до первой реальной фичи.
Проблема: один экран «Заказы» требует компонента из components/, хука из hooks/, вызова к серверу из api/ и типов из types/. Чтобы разобраться в одной фиче — нужно скакать по четырём папкам. Когда фичу удаляют, файлы остаются разбросанными, потому что забыли обойти все папки.
Это называется организацией по техническому типу, а не по смыслу.
Раскладка по фичам — правило «рядом лежит»
Идиоматичный подход для React-проектов: всё, что относится к одной фиче, лежит в одной папке.
src/
features/
products/
ProductList.tsx
ProductCard.tsx
useProducts.ts // загрузка данных фичи
api.ts // вызовы к серверу
types.ts // типы фичи
orders/
OrderList.tsx
useOrders.ts
api.ts
types.ts
shared/
ui/ // Button, Input, Modal — переиспользуются везде
lib/ // общие утилиты: formatDate, formatPrice
app/
router.tsx
App.tsx
Три зоны:
features/— каждая фича живёт в своей папке. Код фичи не лезет во внутренности другой.shared/— то, что нужно нескольким фичам: примитивные UI-компоненты и общие вспомогательные функции.app/— точка сборки: маршрутизатор, корневой компонент, глобальные провайдеры.
Удалить фичу теперь просто: удаляешь папку. Всё, что относилось только к ней, уходит вместе.
Компонент как единица
Компонент в React — это функция, которая принимает данные и возвращает разметку. Базовое правило: один компонент — одна задача.
type ProductCardProps = {
name: string;
price: number;
onOpen: (id: string) => void;
};
export function ProductCard({ name, price, onOpen }: ProductCardProps) {
return (
<article className="product-card">
<h3>{name}</h3>
<p>{price} ₽</p>
</article>
);
}
Компонент получает данные через props и сообщает о действиях пользователя через колбэки (onOpen). Он не знает, откуда пришли данные и что произойдёт после нажатия — это не его дело. Такой компонент можно переиспользовать в любом месте и легко проверить в тестах.
Имена компонентов — с заглавной буквы (ProductCard, не productCard). Каждый компонент — в отдельном файле с таким же именем.
Когда компонент разрастается и начинает делать слишком много — это сигнал разбить его на несколько меньших.
Типизация props
TypeScript в React-проекте — не дополнительный инструмент, а способ зафиксировать контракт между компонентами. Props описывают явно: что компонент принимает, что обязательно, что нет.
type ButtonProps = {
variant: "primary" | "secondary";
disabled?: boolean; // знак ? — необязательное поле
onClick: () => void;
children: React.ReactNode;
};
Несколько приёмов, которые сразу дают результат:
- Объединение строковых значений (
"primary" | "secondary") вместо простоstring— компилятор не даст передать неверный вариант. disabled?со знаком вопроса — поле необязательное, без него компонент тоже работает.children: React.ReactNode— стандартный тип для всего, что вложено в компонент.
Строгий режим в tsconfig.json ("strict": true) включают с первого дня: он ловит ошибки, которые иначе обнаружатся только в браузере.
Логика и разметка — два разных уровня
Компонент отвечает за то, как выглядит экран. Логика — загрузка данных, вычисления, работа с состоянием — живёт в хуках.
// хук — логика загрузки данных
function useProducts() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchProducts().then(data => {
setProducts(data);
setLoading(false);
});
}, []);
return { products, loading };
}
// компонент — только отображение
function ProductList() {
const { products, loading } = useProducts();
if (loading) return <p>Загрузка...</p>;
return (
<ul>
{products.map(p => <ProductCard key={p.id} {...p} />)}
</ul>
);
}
Когда логика в хуке, а не размазана по компоненту — её можно переиспользовать в другом компоненте и проверить отдельно от UI.
Граница со стороны сервера
Всё, что приходит с сервера, держат в api.ts и types.ts внутри папки фичи. Компоненты не делают запросы к серверу напрямую — они работают с данными, которые уже получил хук.
products/
api.ts ← fetchProducts(), fetchProductById()
types.ts ← Product, ProductListResponse
useProducts.ts ← вызывает api.ts, возвращает данные хуку
ProductList.tsx ← использует useProducts()
Если типы ответов сервера описаны в OpenAPI-контракте, их можно сгенерировать автоматически — тогда граница между frontend и backend становится типобезопасной: изменение контракта сразу видно на обоих концах.
Коротко
- Раскладка по фичам: всё, что относится к одной задаче, лежит в одной папке.
- Три зоны:
features/(домены),shared/(общее),app/(точка сборки). - Компонент — функция с одной задачей; принимает данные через props, сообщает о действиях через колбэки.
- Props типизируют явно; строгий режим TypeScript включают с первого дня.
- Логику — загрузку данных, вычисления — выносят в хуки, компонент остаётся про отображение.
- Вызовы к серверу и типы ответов держат в
api.ts/types.tsвнутри папки фичи.
Что почитать дальше
- Компоненты — как строить переиспользуемые компоненты с правильными границами.
- Состояние — когда нужно локальное состояние, а когда глобальное.
- Загрузка данных — паттерны работы с сервером из React-приложения.