Компонентный подход в вёрстке: как не сломать себе проект
Компоненты обещают переиспользование и порядок, но чаще дают дерево из пятидесяти обёрток и стили, которые текут наружу. Разбираем, где проходит граница компонента и чем платишь за каждый подход.

Кнопка в проекте существует в семи вариантах, три из них отличаются только цветом, и ни один нельзя переиспользовать, потому что все завязаны на класс родителя. Знакомая ситуация — прямое следствие того, что компоненты нарезали не по границам ответственности, а по кускам макета. Разберём, как решать, что делать компонентом, где эта граница проходит и чем именно платишь за каждое решение. Речь про фронтенд на React 18/19 и Vue 3, но принципы нарезки от фреймворка не зависят.
Главная ошибка: компонент = кусок макета
Дизайнер прислал экран, вы разбили его на визуальные блоки — шапка, карточка, футер — и завели по компоненту на каждый. Через месяц карточка товара понадобилась в корзине, но там у неё нет кнопки «в избранное», зато есть счётчик количества. Вы добавляете пропсы showFavorite и showCounter, потом variant, потом isCompact. Компонент превращается в свалку флагов, где половина комбинаций не встречается ни на одном экране, но их всё равно надо тестировать.
Граница компонента проходит не по визуальному блоку, а по единице ответственности: что этот кусок UI знает и за что отвечает. Кнопка отвечает за клик и своё состояние (disabled, loading). Она не должна знать, что живёт в карточке товара.
Если компонент нельзя описать одним предложением без слова «и» — он делает слишком много. «Показывает товар и добавляет в корзину и открывает модалку» — это три компонента, слипшихся в один.
Presentational и container: разделяй данные и вид
Классическое деление, которое до сих пор работает, хотя термин из эпохи Redux-connect. Идея простая: одни компоненты знают, как выглядит UI, другие — откуда берутся данные.
- Presentational (dumb) — принимает всё через пропсы, ничего не запрашивает, не лезет в глобальное состояние. Ему всё равно, откуда пришёл список товаров: из API, из мока в Storybook или из теста.
- Container (smart) — тянет данные, держит состояние, обрабатывает мутации и передаёт готовое вниз через пропсы.
Практический критерий: если в компоненте есть fetch, useQuery или обращение к стору — это container. Он не должен содержать разметку сложнее одного враппера. Как только вам захотелось внутри контейнера написать три уровня div с классами — выносите их в отдельный presentational-компонент.
// React 18. Presentational: ничего не знает про источник данных
function ProductCard({ title, price, onAddToCart }) {
return (
<article className="product-card">
<h3>{title}</h3>
<span>{price} ₽</span>
<button onClick={onAddToCart}>В корзину</button>
</article>
);
}
// Container: тянет данные и решает, что делать по клику
function ProductCardContainer({ productId }) {
const { data } = useProduct(productId); // ваш хук поверх React Query или SWR
const addToCart = useCart((state) => state.add);
if (!data) return <ProductCardSkeleton />;
return (
<ProductCard
title={data.title}
price={data.price}
onAddToCart={() => addToCart(data.id)}
/>
);
}
Выигрыш: ProductCard тривиально тестируется и открывается в Storybook без мока сети. Цена: на каждый смысловой блок появляется по два файла, и для мелких компонентов это избыточно. Кнопку в контейнер заворачивать не надо.
Пропсы: где проходит граница между «настраиваемо» и «свалка»
Компонент с пятнадцатью необязательными пропсами — это не гибкий компонент, а несколько разных компонентов, которые побоялись разделить. Три сигнала, что пора резать:
- Булевы флаги, которые меняют структуру.
isCompact, который убирает половину разметки, — признак двух разных компонентов. Разные структуры → разные компоненты, а не один с ветвлением по флагу. - Взаимоисключающие пропсы. Если
showFavoriteиshowCounterникогда не бываютtrueодновременно — это скрытыйvariant, и лучше сделать его явным union-типом. - Пропсы для вложенных детей.
headerColor,headerPadding,headerAlign— вы пробрасываете стили насквозь. Это композиция черезchildrenили слот, а не плоский набор пропсов.
Композиция вместо конфигурации
Вместо флага showFooter и пропса footerContent отдайте место наружу — пусть родитель сам решит, что туда положить.
// Вместо <Card showFooter footerContent={...} />
// даём слот и не знаем, что в него положат
function Card({ children, footer }) {
return (
<div className="card">
<div className="card__body">{children}</div>
{footer && <div className="card__footer">{footer}</div>}
</div>
);
}
// Использование: Card не знает и не должен знать про кнопки
<Card footer={<Button>Оплатить</Button>}>
<ProductList />
</Card>
Во Vue 3 то же самое делают именованные слоты (<slot name="footer">), в веб-компонентах — нативный <slot>. Композиция масштабируется без роста числа пропсов: добавили новый сценарий — просто передали другое содержимое.
Стили: как не дать им течь наружу
Компонент, который переиспользуется, не имеет права навязывать внешние отступы. margin-bottom: 16px внутри карточки — это удобно ровно до момента, когда карточку положили в грид с gap, и снизу появился лишний отступ. Правило: внешние отступы задаёт родитель, внутренние — сам компонент. Компонент отвечает за то, что у него внутри, а не за то, как он стоит среди соседей.
Способ изоляции стилей зависит от стека, и у каждого своя цена:
| Подход | Изоляция | Цена |
|---|---|---|
| CSS Modules | Хеш классов на сборке, коллизий нет | Динамические стили неудобны, нужен сборщик |
| CSS-in-JS (styled-components и т.п.) | Полная, стили привязаны к компоненту | Рантайм-накладные, вопросы с SSR и стримингом |
| Utility-first (Tailwind) | Нет глобальных классов по смыслу | Разметка распухает, переиспользование через @apply или компоненты |
| BEM + глобальный CSS | Ручная, держится на дисциплине именования | Легко нарушить, ревью ловит не всё |
Универсального правильного нет. CSS Modules — разумный дефолт для React без сильных требований к динамике. Tailwind окупается на больших командах, где важна предсказуемость и не жалко многословной разметки. CSS-in-JS даёт максимум гибкости, но добавляет рантайм — под серверным рендерингом с потоковой отдачей это уже не бесплатно.
Когда что выбирать
- Presentational/container — когда компонент реально переиспользуется в разных контекстах или его нужно показывать в Storybook и покрывать unit-тестами. Для одноразового экрана деление избыточно.
- Композиция через children/слоты — как только у компонента появляется третий структурный флаг. Раньше — преждевременно.
- Явный
variantвместо булевых флагов — когда вариантов конечное число и они взаимоисключающие (кнопка: primary, secondary, ghost). - Разбить на два компонента — когда структура разметки различается сильнее, чем набор классов.
Что ломается на практике
- Prop drilling. Пропс проходит через пять уровней, чтобы попасть в лист. Симптом того, что данные не там, где надо. Лечится контекстом (React Context, Vue provide/inject) или подъёмом контейнера ближе к листу.
- Ре-рендеры от неаккуратной композиции. Передаёте новый объект или стрелку инлайном в мемоизированный дочерний компонент —
React.memoперестаёт помогать, потому что ссылка каждый раз новая. Стабилизируйте черезuseMemo/useCallbackтам, где ниже стоит мемо. - Течь стилей. Компонент задал себе
margin, и вёрстка поехала при переиспользовании. Ловится только глазами и договорённостью в команде. - Ложное переиспользование. Два компонента выглядят одинаково сейчас, их слили в один, а через месяц требования разошлись, и в общий компонент насыпали флагов. Одинаковый вид — не повод для общего компонента, если у них разная судьба.
Что делать дальше
Перед тем как заводить компонент, ответьте на два вопроса: за что он отвечает (одним предложением без «и») и кто задаёт ему внешние отступы. Если не можете ответить на первый — режьте, если задумались над вторым — уберите margin наружу. Настройте Storybook на presentational-компоненты: если компонент не открывается там без мока сети, он смешал вид и данные. В проде смотрите на число ре-рендеров через React DevTools Profiler — рост на ровном месте обычно указывает на нестабильные пропсы в composition. Инвалидация кеша данных и работа со стором — отдельная тема, здесь мы говорили только про нарезку и границы компонентов.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: React — Passing Props to a Component (официальная документация), Vue 3 — Slots (официальная документация)
Частые вопросы
У меня не React и не Vue, а Svelte или веб-компоненты. Принципы те же?
Не сработало: React.memo не помогает, компонент всё равно ре-рендерится.
Когда деление на presentational и container — избыточно?
Сколько пропсов у компонента — это уже много?
Стоит ли переписывать существующий проект под этот подход целиком?
CSS-in-JS замедляет приложение?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.