Композиция вместо наследования
В React нет наследования компонентов — базовый компонент специализируют, рендеря его с предустановленными пропсами и children, а не расширяя. Спрашивай «содержит/использует ли X — Y», и следи за цепочками обёрток, где хватило бы одного проп-варианта.
Если ты пришёл в React из классического ООП, твой инстинкт для «PrimaryButton — это разновидность Button» — расширить базовый класс и переопределить метод. Здесь этот инстинкт неверен, и React не даёт тебе API, чтобы его реализовать. Нет extends Button, нет super.render(), нет защищённого метода для переопределения. Механизм переиспользования, который вручает тебе фреймворк, другой.
Ты специализируешь компонент так же, как собирал бы сантехнику: ты не наследуешь трубу — ты соединяешь детали. PrimaryButton не наследует Button — он рендерит Button с уже заполненными пропсами. Как только это щёлкает, целая категория вопросов «как мне сделать вариант?» схлопывается в один приём: оберни и предустанови.
После этого урока ты можешь специализировать базовый компонент, компонуя его — рендеря с предустановленными пропсами и пробрасывая children, — вместо того чтобы тянуться к наследованию; можешь применять тест «содержит/использует ли X — Y?», чтобы решить о структуре; знаешь, почему композиция держит связанность низкой; и можешь распознать режим отказа, когда композиция переприменена в цепочку односоставных обёрток, которую заменил бы один проп-вариант.
В React нет наследования компонентов — примитив переиспользования это композиция, а не подклассы. Нет способа extend-нуть компонент и переопределить его рендер. Что React даёт взамен: компонент может отрендерить другой компонент, передать ему пропсы и передать children. «Специализация» поэтому означает «компонент, который рендерит базовый с уже сделанными выборами». Это не ограничение, которое ты обходишь; это сама модель.
type ButtonProps = React.ComponentProps<"button"> & {
variant?: "primary" | "secondary" | "ghost";
};
function Button({ variant = "secondary", className = "", ...rest }: ButtonProps) {
return <button className={`btn btn--${variant} ${className}`} {...rest} />;
}Button — это база. Всё специализированное ниже будет рендерить его, а не расширять.
Специализируй, предустанавливая пропсы и пробрасывая остальное, — это весь паттерн. PrimaryButton — это просто Button с уже выбранным variant. Ты пробрасываешь children и оставшиеся пропсы, чтобы специализированный компонент оставался drop-in-заменой базы.
function PrimaryButton(props: ButtonProps) {
// предустанавливаем один проп, пробрасываем всё остальное (children, onClick, type, ...)
return <Button variant="primary" {...props} />;
}
// использование идентично обычному Button — children проходят насквозь нетронутыми
<PrimaryButton onClick={save}>Save changes</PrimaryButton>;Заметь, что покупает тебе {...props}: каждый проп, который принимает Button, PrimaryButton принимает бесплатно, без списка для поддержки. Если завтра Button обзаведётся пропом disabled или aria-label, PrimaryButton уже пробрасывает его. Это композиция делает работу, которую наследование лишь притворяется, что делает, — минус хрупкая связанность с базовым классом.
children — это слот композиции, через него содержимое базы поставляется снаружи. IconButton не переопределяет, как Button рендерит; он рендерит Button, чьи children он скомпоновал. База никогда не знает и не заботится, какое содержимое получила.
function IconButton({ icon, children, ...rest }: ButtonProps & { icon: React.ReactNode }) {
return (
<Button {...rest}>
<span className="btn__icon" aria-hidden>{icon}</span>
{children}
</Button>
);
}
<IconButton icon={<TrashIcon />} variant="ghost" onClick={remove}>Delete</IconButton>;IconButton компонует в слот children у Button. Button отрендерил {children} (через спред ...rest, доходящий до нижележащего <button>); IconButton решил, какими будут эти children. Ни один метод базы не был переопределён — содержимое было передано внутрь. «Передай содержимое через children» заменяет «переопредели метод рендеринга».
Ментальный тест уровня senior: спроси «использует/содержит ли X — Y?» (композиция), прежде чем «является ли X — Y?» (наследование). Формулировка отношения как содержит/использует почти всегда даёт React-ответ и держит связанность низкой — специализированный компонент зависит только от публичных пропсов Button, а не от его внутренностей, поэтому не может сломаться при смене реализации Button. Наследование привязывает подкласс к защищённой поверхности родителя; композиция привязывает обёртку только к публичному контракту пропсов.
// мышление «является» → хочет расширять (нет React API для этого, и связало бы туго)
// мышление «использует» → рендерит Button с предустановками (React-ответ, слабая связанность)
function DangerButton(props: ButtonProps) {
return <Button variant="ghost" className="btn--danger" {...props} />;
}Композиция почти всегда правильный ответ в React именно потому, что зависимость так тонка: обёртку, которая лишь читает пропсы Button, гораздо проще понять и менять, чем приваренную к его нутру.
Одна фича, два мышления — отрефактори рефлекс наследования в композицию. Дизайнер просит три разновидности кнопки: primary, icon и состояние «loading», которое показывает спиннер и отключает кнопку.
ООП-рефлекс (который React даже не может выразить, показан как псевдокод, чтобы назвать соблазн):
// ✗ невалидный React — нет класса для расширения и нет render для переопределения
class LoadingButton extends Button {
render() {
return super.render({ disabled: this.props.loading, children: <Spinner /> });
}
}Версия с композицией — каждый вариант рендерит базу с сделанными выборами, содержимое передано через children:
function LoadingButton({ loading, children, ...rest }: ButtonProps & { loading?: boolean }) {
return (
<Button disabled={loading} aria-busy={loading} {...rest}>
{loading ? <Spinner /> : children}
</Button>
);
}
// все три — drop-in Button-ы, скомпонованы, а не расширены:
<PrimaryButton onClick={save}>Save</PrimaryButton>
<IconButton icon={<TrashIcon />}>Delete</IconButton>
<LoadingButton loading={isSaving} onClick={save}>Save</LoadingButton>Каждый специализированный компонент зависит только от публичных пропсов Button (disabled, aria-busy, children, остальное спредом). Ни один не лезет во внутренности Button, поэтому изменение того, как Button себя рисует, не может их сломать. Эта слабая связанность — гарантированная тем фактом, что обёртка может только говорить с пропсами, — и есть структурная выгода, которую композиция покупает над наследованием, и потому композиция почти всегда React-ответ.
▸Почему это работает
Почему React намеренно не имеет наследования? Потому что работа компонента — возвращать UI из пропсов, и этот контракт чисто компонуется: компонент, возвращающий <Button>, сам всего лишь ещё один компонент, возвращающий UI. Наследование позволило бы подклассу лезть во внутренности рендера базы и её защищённое состояние, воссоздавая тугую связанность и проблему хрупкого базового класса, с которыми ООП-фреймворки UI боролись десятилетиями. Предлагая только пропсы и children, React заставляет каждое отношение переиспользования проходить через публичный контракт — а это ровно то свойство, что держит большие деревья компонентов изменяемыми.
▸Частая ошибка
Режим отказа — это композиция, превращённая в собственную косвенность: цепочка обёрток — Button → FancyButton → SuperFancyButton → MegaButton — где каждый слой предустанавливает ещё один проп и не добавляет ничего больше. Теперь место вызова за четыре файла от реального <button>, каждый проп должен протянуться через четыре спреда, а чтобы прочитать код, нужно открыть четыре модуля и узнать, что «Mega» просто значит variant="primary" плюс класс с бо́льшим паддингом. Когда единственная разница между слоями — это значение, это значение принадлежит одному пропу на базе (<Button variant="primary" size="lg">), а не новому компоненту на каждую комбинацию. Честный счёт: если обёртка существует только чтобы предустановить пропсы и используется в одном месте, это, вероятно, проп-вариант в костюме компонента. Компонуй, чтобы варьировать структуру или содержимое; используй проп, чтобы варьировать значение.
Тебе нужны кнопки 'primary' и 'secondary'. Коллега предлагает Button → PrimaryButton → LargePrimaryButton → CtaButton, каждая — однострочная обёртка, предустанавливающая ещё один проп, каждая используется в одном месте. Каков senior-вывод?
В React нет наследования компонентов — нельзя extend-нуть компонент или переопределить его рендер. Ты специализируешь через композицию: рендеришь базовый компонент с предустановленными пропсами и пробрасываешь children и остальное через {...props}, так что специализированный компонент остаётся drop-in-заменой базы и наследует всю её поверхность пропсов бесплатно. PrimaryButton рендерит <Button variant="primary" {...props} />; IconButton компонует children у Button; LoadingButton предустанавливает disabled и подменяет содержимое. Senior-тест — «использует/содержит ли X — Y?» (композиция), а не «является ли X — Y?» (наследование) — формулировка так даёт React-ответ и держит связанность низкой, ведь обёртка может зависеть лишь от публичных пропсов базы, никогда от её внутренностей. Но у композиции есть режим отказа: цепочки обёрток (Button → FancyButton → SuperFancyButton), где каждый слой просто предустанавливает значение. Когда единственная разница — это значение, оно принадлежит одному пропу variant/size на базе, а не компоненту на комбинацию. Компонуй, чтобы варьировать структуру или содержимое; используй проп, чтобы варьировать значение.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.