Коротко: Разбираем контролируемые и неконтролируемые компоненты в React: гибридный паттерн, хук useControllableState, типизация на TypeScript и интеграция в UI-kit.
При проектировании переиспользуемого UI-kit разработчики часто сталкиваются с дилеммой: сделать компоненты строго контролируемыми (controlled) или отдать управление внутреннему состоянию и DOM (uncontrolled). Жесткий выбор в пользу только одного подхода неизбежно бьет по удобству разработки (DX): избыточный контроль заставляет писать десятки лишних строк бойлерплейта на простых формах, а исключительно неконтролируемые компоненты лишают гибкости при построении сложной бизнес-логики.
Библиотеки уровня Radix UI, Base UI или Chakra UI давно решили эту проблему гибридным подходом. Разберем механику каждого паттерна, типовые архитектурные ошибки и напишем гибкий кастомный хук для одновременной поддержки обоих режимов со строгой типизацией на TypeScript.
Фундаментальная разница: как React управляет состоянием
Разница между контролируемыми и неконтролируемыми компонентами сводится к тому, где находится источник истины (Single Source of Truth) — в React-состоянии приложения или внутри самого DOM-узла / локального состояния инкапсулированного компонента.
+-----------------------------------------------------------------+
| Контролируемый подход |
| |
| Parent Component [State] ---( value )---> UI-Kit Component |
| ^ | |
| +------------( onChange(newValue) )--------+ |
+-----------------------------------------------------------------+
+-----------------------------------------------------------------+
| Неконтролируемый подход |
| |
| Parent Component ---( defaultValue )-> UI-Kit Comp |
| | [DOM / State] |
| ( submit / ref.current ) <-----------------------------+ |
+-----------------------------------------------------------------+
Контролируемый подход
В контролируемом компоненте данные целиком управляются внешним кодом через props (обычно пара value и onChange). Компонент является «чистым» отображением входящих данных:
const [email, setEmail] = useState('');
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
Преимущества:
- Полная предсказуемость: родительский компонент в любой момент знает точное значение и может мгновенно реагировать на ввод.
- Синхронная валидация и маскирование: легко блокировать ввод недопустимых символов или форматировать номер телефона на лету.
- Управление связанными полями: изменение одного поля может сразу модифицировать значения других контролов.
Недостатки:
- Ререндеры: каждый введенный символ вызывает ререндер родительского компонента со всем его поддеревом (если не выстроена точечная мемоизация).
- Бойлерплейт: для каждого поля требуется явное объявление состояния и обработчика.
Неконтролируемый подход
Неконтролируемый компонент хранит данные в собственном локальном стейте или полагается на нативное поведение DOM-дерева. Исходное значение задается через defaultValue, а актуальные данные считываются по требованию с помощью ref или нативного FormData при отправке формы:
const inputRef = useRef<HTMLInputElement>(null);
<Input
defaultValue="john@example.com"
ref={inputRef}
/>
Преимущества:
- Производительность: ввод текста изолирован внутри DOM, родительские компоненты не перерисовываются на каждый символ.
- Простая интеграция с библиотеками форм: бесшовная связка с инструментами на базе подписок (например, React Hook Form).
- Минимум кода: для простых статических форм не нужно создавать отдельные стейты под каждое поле.
Недостатки:
- Сложнее реализовать динамическую условную логику между полями без императивного чтения
ref. - Ограниченные возможности для мгновенного визуального форматирования значений непосредственно в процессе набора.
Ограничения крайних подходов в UI-Kit
Дизайн-система обслуживает десятки продуктовых команд с принципиально разными задачами. Односторонний выбор неизбежно приводит к архитектурным тупикам.
Проблема принудительного контроля
Если UI-kit предоставляет компоненты вроде Select, Tabs или Accordion исключительно в контролируемом виде, разработчики вынуждены создавать промежуточные стейты даже для простейших статичных интерфейсов:
// Неудобно: библиотека заставляет контролировать табы там, где это не требуется
const [activeTab, setActiveTab] = useState('profile');
<Tabs value={activeTab} onChange={setActiveTab}>
<Tab id="profile">Профиль</Tab>
<Tab id="settings">Настройки</Tab>
</Tabs>
Это перегружает кодовую базу лишним состоянием, ухудшает переиспользуемость и провоцирует ошибки синхронизации.
Ошибка переключения режимов (uncontrolled to controlled)
Распространенная ошибка при разработке библиотечных компонентов — передача undefined в качестве начального value:
// Ошибка в родительском коде или внутри UI-компонента
const [user, setUser] = useState<{ name?: string }>({});
// На первом рендере user.name равен undefined -> инпут инициализируется как uncontrolled.
// После загрузки данных user.name становится строкой -> инпут переходит в controlled.
<Input value={user.name} onChange={(e) => setUser({ name: e.target.value })} />
React отреагирует предупреждением в консоли:
Warning: A component is changing an uncontrolled input to be controlled.
Чтобы этого избежать, компонент должен либо нормализовать value (например, приводя к пустой строке через value ?? ''), либо явно разграничивать режимы работы внутри своей архитектуры.
Гибридный паттерн: хук useControllableState
Индустриальный стандарт для дизайн-систем — гибридная модель. Компонент проверяет наличие внешнего пропса value. Если он передан — компонент работает в контролируемом режиме, если нет — переключается на внутренний useState, инициализированный через defaultValue.
Реализуем кастомный хук useControllableState:
import { useState, useCallback, useRef, useEffect } from 'react';
interface UseControllableStateProps<T> {
value?: T;
defaultValue?: T | (() => T);
onChange?: (value: T) => void;
}
export function useControllableState<T>({
value: propValue,
defaultValue,
onChange,
}: UseControllableStateProps<T>): [T, (next: T | ((prev: T) => T)) => void] {
const isControlled = propValue !== undefined;
const isControlledRef = useRef(isControlled);
useEffect(() => {
isControlledRef.current = isControlled;
}, [isControlled]);
const [uncontrolledValue, setUncontrolledValue] = useState<T>(() => {
if (defaultValue !== undefined) {
return typeof defaultValue === 'function'
? (defaultValue as () => T)()
: defaultValue;
}
return undefined as unknown as T;
});
const currentValue = isControlled ? (propValue as T) : uncontrolledValue;
const setValue = useCallback(
(next: T | ((prev: T) => T)) => {
const nextValue = typeof next === 'function'
? (next as (prev: T) => T)(currentValue)
: next;
if (!isControlledRef.current) {
setUncontrolledValue(nextValue);
}
onChange?.(nextValue);
},
[currentValue, onChange]
);
return [currentValue, setValue];
}
Применение хука в компоненте Switch (Toggle)
import React, { forwardRef } from 'react';
import { useControllableState } from './useControllableState';
export interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange' | 'value' | 'defaultValue'> {
checked?: boolean;
defaultChecked?: boolean;
onChange?: (checked: boolean) => void;
}
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
const {
checked: checkedProp,
defaultChecked = false,
onChange,
disabled,
className,
...restProps
} = props;
const [checked, setChecked] = useControllableState({
value: checkedProp,
defaultValue: defaultChecked,
onChange,
});
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (disabled) return;
setChecked(!checked);
restProps.onClick?.(e);
};
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={handleClick}
ref={ref}
className={`switch ${checked ? 'switch--checked' : ''} ${className ?? ''}`}
{...restProps}
>
<span className="switch__thumb" />
</button>
);
});
Switch.displayName = 'Switch';
Теперь Switch одинаково предсказуемо ведет себя в обоих сценариях:
// 1. Неконтролируемый режим (простой UI без внешнего стейта)
<Switch defaultChecked={true} onChange={(val) => console.log('Toggled:', val)} />
// 2. Контролируемый режим (зависимости, внешняя бизнес-логика)
const [notifications, setNotifications] = useState(false);
<Switch checked={notifications} onChange={setNotifications} />
Строгая типизация API через TypeScript
С помощью TypeScript можно на уровне компиляции предотвратить передачу некорректных комбинаций параметров — например, передачу value без обязательного обработчика onChange, если дизайн-система требует строгого контракта.
Для этого применяются дискриминированные объединения (Discriminated Unions):
type BaseSelectProps<T> = {
options: Array<{ label: string; value: T }>;
disabled?: boolean;
};
type ControlledSelectProps<T> = BaseSelectProps<T> & {
value: T;
onChange: (value: T) => void;
defaultValue?: never;
};
type UncontrolledSelectProps<T> = BaseSelectProps<T> & {
value?: never;
onChange?: (value: T) => void;
defaultValue?: T;
};
export type SelectProps<T> = ControlledSelectProps<T> | UncontrolledSelectProps<T>;
При такой типизации одновременная передача value и defaultValue вызовет ошибку еще на этапе сборки:
// Ошибка компиляции: Type 'string' is not assignable to type 'undefined'
<Select
options={items}
value={currentValue}
defaultValue="default"
onChange={handleChange}
/>
Контролируемость за пределами текстовых полей
Паттерн актуален не только для полей ввода, но и для всех интерактивных компонентов с внутренним состоянием:
- Модальные окна и оверлеи:
open/defaultOpen,onOpenChange. - Аккордеоны и раскрывающиеся списки:
expandedIds/defaultExpandedIds. - Табы:
activeTab/defaultActiveTab. - Пагинация:
currentPage/defaultPage.
// Неконтролируемый Dropdown — сам управляет открытием и закрытием
<DropdownMenu defaultOpen={false}>
<DropdownMenu.Trigger>Действия</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={handleEdit}>Редактировать</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
// Контролируемый Dropdown — открытие подчиняется внешнему стейту
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<DropdownMenu.Trigger>Действия</DropdownMenu.Trigger>
<DropdownMenu.Content>...</DropdownMenu.Content>
</DropdownMenu>
Нюанс с анимациями закрытия
В модальных окнах или выпадающих списках при контролируемом закрытии (open={false}) родительский компонент может мгновенно размонтировать узел из дерева. Если в UI-kit заложены CSS-анимации выхода (exit transitions), компонент должен иметь внутренний статус жизненного цикла (mounted/unmounted), удерживающий узел в DOM до завершения анимации, даже когда внешний флаг open уже перешел в false.
Интеграция с библиотеками форм (React Hook Form)
Современные библиотеки управления формами строятся на базе неконтролируемых компонентов для достижения высокой скорости рендеринга.
Чтобы нативный компонент дизайн-системы бесшовно подключался к React Hook Form через метод register, он обязан:
- Корректно пробрасывать
refна внутренний DOM-элемент черезforwardRef. - Принимать стандартные атрибуты
name,onBlur,onChange.
// Интеграция через ref (максимальная производительность)
const { register, handleSubmit } = useForm<FormData>();
<Input
{...register('username', { required: true })}
placeholder="Введите логин"
/>
Для сложных составных компонентов (кастомный DatePicker или MultiSelect без нативного инпута) гибридная архитектура позволяет использовать обертку Controller:
<Controller
name="birthDate"
control={control}
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
ref={field.ref}
/>
)}
/>
Сравнительный чеклист проектирования компонентов
| Компонент | Рекомендуемый дефолтный режим | Критические свойства API | Основные риски |
|---|---|---|---|
| Input / Textarea | Uncontrolled (с поддержкой Controlled) | value, defaultValue, ref, onChange |
Потеря фокуса, задержки ввода при ререндере |
| Checkbox / Switch | Гибридный | checked, defaultChecked, onChange |
Рассинхронизация с нативным DOM-состоянием |
| Select / Combobox | Гибридный | value, defaultValue, onValueChange |
Сложность позиционирования и фокуса клавиатуры |
| Modal / Dialog | Гибридный | open, defaultOpen, onOpenChange |
Преждевременный демонтаж DOM до завершения анимации |
| Tabs | Uncontrolled (с поддержкой Controlled) | value, defaultValue, onValueChange |
Избыточный ререндер контента неактивных вкладок |
Часто задаваемые вопросы (FAQ)
1. Можно ли делать компоненты в дизайн-системе строго контролируемыми?
Делать базовые компоненты (Input, Checkbox, Select) строго контролируемыми не рекомендуется. Это превращает дизайн-систему в источник избыточного бойлерплейта и создает барьеры при интеграции с инструментами, ориентированными на неконтролируемые поля.
2. Почему React предупреждает об изменении с uncontrolled на controlled?
Это происходит, когда значение value изначально передается как undefined, а позже обновляется строкой или числом (например, после завершения запроса к API). Для исправления передавайте явный fallback (value={user.name ?? ''}) либо инициализируйте компонент через defaultValue.
3. Зачем прокидывать ref через forwardRef для всех компонентов дизайн-системы?
forwardRef предоставляет прямое управление DOM-узлом. Это необходимо для библиотек форм, программной установки фокуса при валидации, расчетных систем позиционирования всплывающих окон и соблюдения стандартов доступности (WAI-ARIA).
4. Влияет ли хук useControllableState на производительность?
В контролируемом режиме хук передает вызовы onChange наружу и не инициирует обновление собственного стейта, предотвращая лишние циклы отрисовки. В неконтролируемом режиме состояние изолируется внутри самого компонента, полностью избавляя дерево предков от перерисовок.
5. Как обрабатывать сброс формы (form reset) в неконтролируемых компонентах?
Стандартные HTML-элементы сбрасываются браузером автоматически при событии reset на теге <form>. Для кастомных компонентов внутри гибридного хука можно слушать событие сброса родительской формы через ref и программно возвращать локальное состояние к значению defaultValue.
Резюме
Проектирование компонентов дизайн-системы требует взвешенного баланса между автономностью и возможностью централизованного управления. Принуждение к одной модели поведения ограничивает сценарии использования UI-kit.
Внедрение гибких контрактов с поддержкой парных пропсов (value / defaultValue, open / defaultOpen) и централизация этой логики в хуке useControllableState закрывает все продуктовые требования: от легковесных и быстрых форм до комплексных интерфейсов со сложной зависимой валидацией.




.svg.webp)




