Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Проектирование API компонентов
component_design

Проектирование API компонентов

Композиция, области подстановки, управляемые и неуправляемые компоненты, размеченные объединения и устойчивые публичные контракты.

Открыть лабораториюv0.2.0Запускается локально из публичного репозитория

Проектирование API компонентов

Хороший компонент делает правильное использование простым, а незаконное — непредставимым или хотя бы немедленно диагностируемым.

#Результат урока

Вы спроектируете публичный API без множества противоречивых флагов, скрытой связи с приложением и утечки деталей DOM.

Понадобятся свойства, дочернее содержимое, владение состоянием и строгие объединения TypeScript. Итог урока — доступный набор базовых компонентов Alert, Dialog и Select с типизированными контрактами и управляемыми вариантами.

#1. Стабильная ответственность компонента

Компонент получает один связный набор обязанностей. Базовый визуальный компонент знает семантику, расположение и состояния взаимодействия; компонент сценария — данные, права и команды. Импорт клиента 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, который сам читает маршрут, авторизацию и кеш внутри общей библиотеки интерфейса.

#2. Объединение вместо множества флагов

Флаги 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 и выбирать победителя порядком условий.

#3. Управляемый и неуправляемый режимы

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

#4. Именованные области и составные компоненты

Именованные области (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 и неявно изменять произвольное дочернее содержимое.

#5. Свойства DOM и безопасное распространение

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-* и обработчиков.

#6. Полиморфное свойство 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.

#7. Версионирование публичного API

Общая библиотека компонентов — зависимость. Переименование свойства, смена значения по умолчанию, структуры 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-*.

#8. Проверка API на примерах использования

До реализации напишите примеры: простой случай, управляемый режим, асинхронная операция, ошибка и доступность. Если обычный сценарий требует много служебного кода, а опасный — короткий, 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 отклоняет недопустимые сочетания свойств, данные остаются у компонента сценария, а базовый компонент проверяется независимо.

#Источники для сверки

  • React: Passing Props
  • React: Sharing State
  • TypeScript discriminated unions

Устранение неисправностей

Далее: Редьюсеры, Context и внешние хранилища