Композиция, области подстановки, управляемые и неуправляемые компоненты, размеченные объединения и устойчивые публичные контракты.
Хороший компонент делает правильное использование простым, а незаконное — непредставимым или хотя бы немедленно диагностируемым.
Вы спроектируете публичный API без множества противоречивых флагов, скрытой связи с приложением и утечки деталей DOM.
Понадобятся свойства, дочернее содержимое, владение состоянием и строгие объединения TypeScript. Итог урока — доступный набор базовых компонентов Alert, Dialog и Select с типизированными контрактами и управляемыми вариантами.
Компонент получает один связный набор обязанностей. Базовый визуальный компонент знает семантику, расположение и состояния взаимодействия; компонент сценария — данные, права и команды. Импорт клиента API в Button или Dialog связывает библиотеку с приложением. Разделяйте то, что меняется независимо.
Компонент из общей библиотеки, знающий про маршруты и кеш, перестаёт быть переиспользуемым:
// ❌ src/shared/ui/DeleteUserDialog.tsx — библиотека зависит от приложения
function DeleteUserDialog({ userId }: { userId: string }) {
const navigate = useNavigate(); // знание о маршрутизаторе
const { mutate } = useDeleteUser(); // знание о кеше и API
const { can } = usePermissions(); // знание об авторизации
...
}Разделение оставляет базовому компоненту только сценарий взаимодействия:
// ✅ src/shared/ui/ConfirmDialog.tsx — ничего не знает о предметной области
type ConfirmDialogProps = {
title: string;
confirmLabel: string;
pending?: boolean;
onConfirm(): void;
onCancel(): void;
};// ✅ src/features/users/DeleteUserAction.tsx — здесь живут данные, права и команды
function DeleteUserAction({ userId }: { userId: string }) {
const { mutate, isPending } = useDeleteUser();
return <ConfirmDialog title="Удалить пользователя?" confirmLabel="Удалить"
pending={isPending} onConfirm={() => mutate(userId)} onCancel={close} />;
}Теперь ConfirmDialog можно переиспользовать в любом сценарии и протестировать без маршрутизатора, сети и авторизации.
Проверьте себя. Отделите подтверждающий Dialog от команды удаления записи.
Частая ошибка. Сделать DeleteUserDialog, который сам читает маршрут, авторизацию и кеш внутри общей библиотеки интерфейса.
Флаги primary, danger, compact, loading, success быстро создают десятки сочетаний, часть которых бессмысленна. Взаимоисключающие режимы выражают одним variant, а состояния с разными обязательными свойствами — размеченным объединением. Независимые признаки можно оставить отдельными.
type NoticeProps =
| { kind: 'info'; message: string }
| { kind: 'error'; message: string; retry?: () => void }
| { kind: 'success'; message: string; receiptId: string };retry и receiptId доступны только там, где имеют смысл; для варианта info передать номер квитанции нельзя.
Компилятор при этом отклоняет недопустимые сочетания на месте вызова:
<Notice kind="info" message="Черновик сохранён" /> // ✅
<Notice kind="success" message="Оплачено" receiptId="R-42" /> // ✅
<Notice kind="success" message="Оплачено" /> // ❌ нет receiptId
<Notice kind="info" message="Готово" receiptId="R-42" /> // ❌ лишнее свойствоС набором логических флагов все четыре строки прошли бы проверку типов, а разбираться пришлось бы порядком условий в JSX — где «первый победивший» флаг определяет вид, и любая перестановка меняет поведение.
Проверьте себя. Преобразуйте три взаимоисключающих логических свойства в одно объединение вариантов.
Частая ошибка. Разрешить одновременно error и success и выбирать победителя порядком условий.
Переиспользуемый базовый компонент может поддерживать управляемые open и onOpenChange, а также неуправляемый defaultOpen. Внутренняя функция выбирает владельца один раз; после монтирования режим не переключается. Обработчик сообщает следующее состояние и причину, если нужно отличать Escape, кнопку открытия и щелчок снаружи.
Выбор владельца делается один раз, при первом вызове:
// src/shared/ui/useControllableState.ts
export function useControllableState<T>(controlled: T | undefined, defaultValue: T) {
const isControlled = useRef(controlled !== undefined).current; // фиксируем режим
const [internal, setInternal] = useState(defaultValue);
return [isControlled ? (controlled as T) : internal, setInternal] as const;
}Родитель, владеющий состоянием, может отклонить закрытие — например, при несохранённом черновике:
<Dialog
open={open}
onOpenChange={(next, reason) => {
if (!next && reason === 'escape' && hasDraft) return; // закрытие отклонено
setOpen(next);
}}
/>Смешивание режимов даёт расхождение: компонент показывает внутреннее состояние, а родитель считает актуальным своё свойство. Пользователь видит открытый диалог, который «по данным приложения» закрыт.
Проверьте себя. Проверьте управляемый Dialog, где родитель отклоняет закрытие при несохранённом черновике.
Частая ошибка. Смешать open и локальное состояние так, что интерфейс расходится со свойством.
Именованные области (slots) подходят, когда структура фиксирована. Составные компоненты (compound components) дают гибкую вложенную разметку с общим контекстом, например Tabs.List, Tabs.Trigger и Tabs.Content. Контекст хранит только координацию базового компонента, а не всё состояние сценария. При отсутствии провайдера должна возникать понятная ошибка.
Составной API позволяет вызывающей стороне строить разметку свободно, сохраняя координацию внутри:
// src/shared/ui/tabs.tsx
const TabsContext = createContext<{ value: string; setValue: (next: string) => void } | null>(null);
function useTabs() {
const context = useContext(TabsContext);
if (context === null) throw new Error('Tabs.Trigger использован вне Tabs');
return context;
}
export function Tabs({ defaultValue, children }: TabsProps) {
const [value, setValue] = useState(defaultValue);
return <TabsContext.Provider value={{ value, setValue }}>{children}</TabsContext.Provider>;
}
Tabs.Trigger = function Trigger({ value, children }: TriggerProps) {
const tabs = useTabs();
return (
<button role="tab" aria-selected={tabs.value === value} onClick={() => tabs.setValue(value)}>
{children}
</button>
);
};Контекст здесь содержит только выбранную вкладку — координацию, без данных сценария. Альтернатива через cloneElement кажется гибкой, но незаметно переписывает чужие свойства:
// ❌ молча перезапишет onClick, который передала вызывающая сторона
{React.Children.map(children, (child) => cloneElement(child, { onClick: handleSelect }))}Проверьте себя. Соберите Tabs из List, Trigger и Content с одним внутренним контекстом selection.
Частая ошибка. Использовать cloneElement и неявно изменять произвольное дочернее содержимое.
ComponentPropsWithoutRef<"button"> помогает наследовать нативные свойства, но порядок распространения важен. Защищённые role, type и aria-* нельзя случайно перезаписать. Для Button разумно задать type="button" по умолчанию, иначе внутри формы он отправит данные. Обработчики событий можно объединять, не теряя переданный извне обработчик.
// src/Button.tsx
type ButtonProps = React.ComponentPropsWithoutRef<'button'> & { tone?: 'normal' | 'danger' };
function Button({ tone = 'normal', type = 'button', ...props }: ButtonProps) {
return <button {...props} type={type} data-tone={tone} />;
}Явный type после распространения защищает контракт; если вызывающая сторона должна его менять, значение извлекается и передаётся осознанно.
Обратный порядок распространения ломает и семантику, и обработчики:
// ❌ внешние свойства перезаписывают role и aria-*, внешний onClick теряет внутренний
<div role="dialog" aria-modal="true" onClick={handleInternal} {...props} />
// ✅ объединяем обработчики, защищённые атрибуты ставим после распространения
<div
{...props}
role="dialog"
aria-modal="true"
onClick={(event) => {
props.onClick?.(event); // внешний обработчик не потерян
handleInternal(event);
}}
/>Проверьте себя. Поместите Button в форму и докажите, что обычное действие не отправляет её.
Частая ошибка. Безусловно распространять внешние свойства после внутренних aria-* и обработчиков.
as с ограничениямиПолиморфный компонент позволяет выбрать тип элемента, но усложняет вывод свойств, ref и семантику. Button, отрисованный как div, не получает клавиатурное поведение автоматически. Часто безопаснее иметь отдельные базовые компоненты кнопки и ссылки. Свойство as оправдано в низкоуровневой библиотеке с тестами типов.
Два отдельных компонента дают точные типы и правильную семантику без обобщений:
// ✅ каждый со своим набором свойств и своим элементом
function Button(props: React.ComponentPropsWithoutRef<'button'>) { ... }
function LinkButton(props: React.ComponentPropsWithoutRef<'a'>) { ... }Полиморфный вариант требует обобщений и всё равно не переносит поведение:
// ❌ типы усложнились, а <div> не получил ни фокуса, ни Enter, ни Space
<Button as="div" onClick={handleSave}>Сохранить</Button>data-role="button" не делает элемент кнопкой: ни дерево доступности, ни клавиатура о нём не знают. Если полиморфизм действительно нужен — например, ссылка, оформленная как кнопка, — правильный ответ обычно asChild, передающий оформление настоящему семантическому элементу.
Проверьте себя. Сравните типы и клавиатурное поведение Button, LinkButton и чрезмерно общего API с as.
Частая ошибка. Разрешить as="div" и считать data-role эквивалентом button.
Общая библиотека компонентов — зависимость. Переименование свойства, смена значения по умолчанию, структуры DOM или момента вызова обработчика может нарушить совместимость. Контракт фиксируют тестами типов, взаимодействия, доступности и внешнего вида. Устаревший адаптер даёт путь миграции, но не должен жить бесконечно.
Новое свойство добавляют так, чтобы существующий код продолжал работать без правок:
type ButtonProps = {
tone?: 'normal' | 'danger' | 'success'; // 'success' добавлен, значение по умолчанию не менялось
/** @deprecated Используйте tone="danger". Будет удалено в 3.0. */
danger?: boolean;
};
function Button({ tone = 'normal', danger, ...props }: ButtonProps) {
const resolved = danger ? 'danger' : tone; // адаптер на время миграции
...
}Смена значения по умолчанию — тоже несовместимое изменение, даже если публичный тип не изменился: все существующие вызовы начнут выглядеть иначе. Структура DOM входит в контракт по той же причине: от неё зависят внешние стили и связи aria-*, которые ставит вызывающая сторона.
Проверьте себя. Добавьте новый вариант без изменения прежних значений по умолчанию и опишите миграцию.
Частая ошибка. Считать изменение обёрток DOM внутренним, хотя от него зависят стили и связи aria-*.
До реализации напишите примеры: простой случай, управляемый режим, асинхронная операция, ошибка и доступность. Если обычный сценарий требует много служебного кода, а опасный — короткий, API спроектирован неверно. Проверяйте недопустимые состояния и владельца данных. Не добавляйте свойство ради одного потребителя, пока задачу решает композиция.
Примеры пишутся до реализации и читаются как приговор дизайну:
// 1. простой случай — должен быть коротким
<Dialog defaultOpen><Dialog.Title>Справка</Dialog.Title>{help}</Dialog>
// 2. управляемый режим с отклонением закрытия
<Dialog open={open} onOpenChange={guardClose}>…</Dialog>
// 3. асинхронное подтверждение
<ConfirmDialog pending={isPending} onConfirm={submit} onCancel={close} />Если для случая 1 требуется десять строк служебного кода, а случай «закрыть диалог во время отправки, потеряв данные» пишется в одну строку, API подталкивает к ошибке. Признак избыточности — свойство, добавленное ради одного потребителя: чаще всего его заменяет дочернее содержимое.
Проверьте себя. Проверьте Dialog на пяти сценариях и удалите свойства, которые заменяются дочерним содержимым и действиями.
Частая ошибка. Проектировать API только из внутренней реализации компонента.
Переработайте перегруженный Modal с 14 свойствами в составной API Dialog. Отделите кнопку открытия, содержимое и действия, управляемое открытие, подтверждение удаления и асинхронное закрытие. Сохраните семантику и правила фокуса.
Работа готова, когда TypeScript отклоняет недопустимые сочетания свойств, данные остаются у компонента сценария, а базовый компонент проверяется независимо.
Далее: Редьюсеры, Context и внешние хранилища