open atlas
↑ К треку
React с нуля до senior RCT · 12 · 01

Паттерны композиции: compound-компоненты вместо булевых пропсов

Булевы пропсы взрываются в непроверяемую матрицу 2^n. Композиция уводит вариативность в JSX: compound-компоненты делят состояние через контекст с dev-проверками и controlled/uncontrolled дуальностью; инспекция children хрупка; render props побеждают, когда цикл у библиотеки.

RCT Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Кнопка дизайн-системы начинала жизнь с двумя пропсами: 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 с явным списком альтернатив.

Вспомните перед уходом
  1. 01
    Пройдите процедуру решения при расширении API компонента дизайн-системы и назовите провал, который избегает каждая ветка.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.