Паттерны композиции: compound-компоненты вместо булевых пропсов
Булевы пропсы взрываются в непроверяемую матрицу 2^n. Композиция уводит вариативность в JSX: compound-компоненты делят состояние через контекст с dev-проверками и controlled/uncontrolled дуальностью; инспекция children хрупка; render props побеждают, когда цикл у библиотеки.
Кнопка дизайн-системы начинала жизнь с двумя пропсами: variant и size. Через три года и четыре продуктовые команды их стало 23 — loading, loadingText, leftIcon, rightIcon, iconOnly, fullWidth, quiet, destructive, uppercase, asLink, href, tooltip и так далее. За каждым стоял чей-то дедлайн: PM понадобился спиннер, чекаут потребовал опасный стиль, маркетингу — капс. Каждое добавление выглядело дёшево на ревью — один проп, одно условие. Потом релиз сломал продакшен-чекаут: loading вместе с iconOnly рисовали спиннер поверх иконки, потому что эту пару никто никогда не рендерил. И не мог: девять булевых пропсов — это 512 состояний рендера, а снапшот-сьют покрывал 14 из них. Команда заморозила компонент и провела аудит использований — 40% колл-сайтов передавали комбинации пропсов, которые авторы не воображали, и несколько месяцами стояли визуально сломанными без единого баг-репорта. Тем временем Tabs из той же системы, построенный как compound-семейство, впитал три года новых требований — иконки в триггерах, бейджи, ленивые панели — с нулём новых пропсов на самом Tabs, потому что вариативность жила в JSX на месте вызова. Разница была не в таланте. Она была в том, куда каждый API помещает изменения.
Спектр «конфигурация — композиция»
Каждый API компонента живёт на спектре. На одном конце — конфигурация: вызывающий передаёт пропсы, компонент владеет всей структурой. На другом — композиция: вызывающий передаёт JSX, компонент владеет только рамкой вокруг него. Конфигурация закрыта и перечислима — variant="primary" | "ghost" | "danger" — типизированный конечный контракт, который можно исчерпывающе протестировать и задокументировать. Композиция открыта — children принимает что угодно, и в этом весь смысл.
Провал конфигурации — взрыв булевых пропсов. Каждый булев is*/has* удваивает пространство состояний компонента: 9 булевых — это 2⁹ = 512 комбинаций, и взаимодействия между ними (loading × iconOnly, quiet × destructive) — реальные состояния рендера, которые тесты почти наверняка пропускают. Хуже того, булев проп — дорога в один конец: удаление ломает каждого потребителя, поэтому матрица только растёт. Надёжный запах — проп, описывающий содержимое, а не поведение: leftIcon, subtitle, footerActions, loadingText. Содержимое принадлежит вызывающему; в момент, когда вы добавляете проп ради инъекции разметки, API просит children или слот:
// Третий год жизни Button. Каждый проп — чей-то дедлайн.
<Button
variant="primary" size="md" loading loadingText="Saving…"
leftIcon={<SaveIcon />} fullWidth uppercase tooltip="Save the draft"
/>
// Композиция: те же потребности, ноль новых пропсов Button.
<Button variant="primary" size="md" disabled={saving}>
{saving ? <Spinner label="Saving…" /> : <SaveIcon />}
Save draft
</Button>Правило, которое применяют senior-ревьюеры: перечислимая, ограниченная вариативность остаётся пропсом (это решение, которым владеет система — variant, size, tone); произвольная структура становится children или слотом (это контент вызывающего, и система не должна его перечислять).
У кнопки дизайн-системы 14 пропсов, 9 из них булевы. Продукт просит стрелку выпадашки справа — для сплит-кнопки. Какой ход в дизайне API останавливает рост матрицы?
Compound-компоненты: семейство, связанное контекстом
Когда несколько частей должны делить состояние — Tabs с триггерами и панелями, Select с опциями, Accordion с элементами — композиционный ответ называется compound-компонент: семейство компонентов, общающихся через неявный React-контекст, так что вызывающий свободно компонует части, а семейство координируется за кулисами. Форма, к которой сходится каждая серьёзная система:
const TabsCtx = createContext<TabsContextValue | null>(null);
function useTabs(part: string) {
const ctx = useContext(TabsCtx);
if (ctx === null) {
// Падаем громко в development — ровно в точке неправильного использования.
throw new Error(part + " must be rendered inside <Tabs>");
}
return ctx;
}
export function Tabs({ value, defaultValue, onValueChange, children }: TabsProps) {
const [internal, setInternal] = useState(defaultValue ?? null);
const isControlled = value !== undefined;
const active = isControlled ? value : internal;
const select = useCallback((next: string) => {
if (!isControlled) setInternal(next); // uncontrolled: владеем состоянием сами
onValueChange?.(next); // controlled: решает родитель
}, [isControlled, onValueChange]);
const ctx = useMemo(() => ({ active, select }), [active, select]);
return <TabsCtx.Provider value={ctx}>{children}</TabsCtx.Provider>;
}
Tabs.Trigger = function Trigger({ value, children }: TriggerProps) {
const { active, select } = useTabs("<Tabs.Trigger>");
return (
<button role="tab" aria-selected={active === value} onClick={() => select(value)}>
{children}
</button>
);
};Три несущие детали. Первая: дефолт контекста — null, и useTabs на нём бросает исключение — Tabs.Panel, вставленный вне Tabs, падает в точке ошибки с именованным сообщением, вместо сломанного UI или краха на чтении undefined тремя слоями глубже. Вторая: дуальность controlled/uncontrolled — компонент поддерживает и value (состоянием владеет родитель, компонент лишь сообщает намерение через onValueChange), и defaultValue (компонент владеет состоянием сам). isControlled определяется как value !== undefined и должен оставаться неизменным всю жизнь экземпляра — полу-контролируемый компонент, который обновляет внутреннее состояние и читает value, визуально рассинхронизируется, как только родитель пропустит обновление. Третья: значение контекста мемоизировано — каждый потребитель TabsCtx ререндерится при смене идентичности значения, и инлайновый объект здесь перерисовывал бы каждый триггер и панель на каждом рендере Tabs.
Слоты против инспекции children
Есть и более старый способ связать семейство: перебрать children, найти свои части по типу элемента и вживить пропсы через cloneElement. Читается остроумно, а на деле — ловушка:
// Инспекция children привязывает родителя к точному дереву элементов.
function Tabs({ children }) {
return Children.map(children, (child, i) =>
isValidElement(child) && child.type === Tab
? cloneElement(child, { index: i })
: child // фрагмент, обёртка FeatureFlag, кастомный Tab — молча пропущены
);
}Children.map видит непосредственный слой элементов, не глубже. Оберните Tab во фрагмент — и фрагмент окажется одним непрозрачным ребёнком; вкладки внутри невидимы. Оберните в компонент FeatureFlag — и child.type будет FeatureFlag, а не Tab: сопоставление провалится, индекс не придёт. Вынесите две вкладки в локальный компонент BillingTabs — та же тихая поломка, потому что того, что компонент отрендерит, не существует до рендера. Каждый из этих случаев — нормальный, поощряемый рефакторинг, и инспекция children наказывает их все. Поэтому react.dev держит API Children в разделе legacy, и поэтому победили compound-семейства на контексте: контекст достаёт до любого потомка на любой глубине, сквозь любую обёртку, без предположений о форме дерева. А для фиксированных областей, где нужна явная структура — иконка, набор действий — честный инструмент это именованный слот: обычный проп типа ReactNode (icon={<Save />}, actions={...}), перечислимый как конфигурация и открытый как композиция.
Tabs реализован через Children.map с проверкой child.type === Tab для вживления индекса. Коллега оборачивает один Tab в компонент FeatureFlag — и вкладка молча исчезает. Почему?
Где render props всё ещё побеждают
Хуки заменили render props для шаринга логики, но одна ниша структурно их: компонент владеет циклом по данным, а приложение владеет разметкой каждого элемента. Виртуализированный список решает, какие строки существуют, измеряет и позиционирует их — только он знает данные строки и стиль абсолютного позиционирования для каждого видимого индекса. Он обязан вернуть и то и другое вызывающему, на каждую строку, и функция-ребёнок — единственный канал такой формы:
<VirtualList items={rows} rowHeight={32}>
{(row, style) => (
<div style={style} className="row">
{row.symbol} · {row.qty}
</div>
)}
</VirtualList>Обычные children так не могут — children создаются вызывающим до того, как список что-либо узнал. Тест — направление потока данных: когда родитель производит данные на каждый вызов, нужные вызывающему, — render prop; когда контент производит вызывающий — children; когда части делят состояние — compound-семейство. TanStack Virtual и Table, downshift и каждый серьёзный data-grid живут в этой нише намеренно.
▸Почему это работает
Почему контекст победил cloneElement в связке compound-частей, хотя cloneElement проще написать? Потому что они различаются в том, что предполагают. Инспекция на cloneElement предполагает, что элементное дерево на уровне родителя и есть логическая структура — предположение, которое ломает каждый рефакторинг (фрагменты, обёртки, выделение компонентов), молча и без ошибки, указывающей на причину. Контекст предполагает лишь, что часть рендерится где-то ниже провайдера, — единственный инвариант, который рефакторинги сохраняют. Цену контекста — провайдер, хук-страж, мемоизация значения — автор системы платит один раз; цену инспекции вечно платит каждый потребитель при каждом рефакторинге. API дизайн-системы пишется один раз и потребляется тысячи раз, поэтому размен всегда сходится одинаково. Документация react.dev фиксирует вердикт: Children и cloneElement лежат в разделе legacy с явным списком альтернатив.
- 01Пройдите процедуру решения при расширении API компонента дизайн-системы и назовите провал, который избегает каждая ветка.
- 02Как compound-компонент делит состояние, почему контекст строго надёжнее инспекции Children, и в чём дуальность controlled/uncontrolled?
API компонентов живут на спектре «конфигурация — композиция», и senior-навык — класть каждую единицу вариативности на правильный конец. Конфигурация — типизированные пропсы — для закрытых перечислимых решений, которыми владеет дизайн-система: variant, size, tone. Её провал — булев взрыв: каждый is-или-has проп удваивает пространство состояний, девять булевых — это 512 состояний рендера при дюжине протестированных, комбинации взаимодействуют визуально, а пропсы — дорога в один конец: удаление ломает потребителей, и матрица только копится. Надёжный запах — проп, описывающий содержимое, а не поведение; содержимое принадлежит вызывающему — как children или именованный слот ReactNode. Когда части должны координироваться, ответ — compound-семейство: контекст с дефолтом null, хук-страж с именованной ошибкой в точке неправильного использования, мемоизированное значение контекста, чтобы потребители не перерисовывались на каждый рендер родителя, и дуальность controlled/uncontrolled — value плюс onValueChange, когда состоянием владеет родитель, defaultValue, когда компонент, причём контролируемость решается один раз через value !== undefined и не переключается при жизни экземпляра. Старая связка — Children.map с cloneElement — привязывает родителя к точному элементному дереву: фрагменты, обёртки и выделенные подкомпоненты молча ломают сопоставление по типу, поэтому она и живёт в legacy-разделе документации, а победил контекст. И один паттерн пережил эпоху хуков нетронутым: когда компонент владеет циклом по данным — виртуализированные списки, data-grid — и должен отдать каждому вызывающему данные и позиционирование на элемент, render prop — единственный канал нужной формы. Выбирайте по направлению потока данных — и API перестанет обрастать пропсами. Теперь, когда встретишь PR с десятым булевым пропом, спроси себя: это решение, которым владеет система, или содержимое, принадлежащее вызывающему? — и знай, куда направить ревью.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.