← назад к разделу

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-приложения.