Контролируемые и неконтролируемые компоненты
«Я передал в поле value={email} — и в него невозможно ничего напечатать. Что не так?» Вопрос отделяет тех, кто понимает, кто здесь источник правды: React или DOM.
Контролируемый компонент — поле формы, значение которого задаётся состоянием React и меняется только через
onChange. Неконтролируемый — поле, значение которого хранит сам DOM, а React читает его при необходимости черезref.
Задача: почему поле «заморожено»
Вот код, который приносят на собеседование. Пользователь жмёт клавиши — в поле пусто.
function LoginForm() {
const [email, setEmail] = useState('');
return (
<form>
{/* value есть, onChange нет — поле не даст ничего ввести */}
<input type="email" value={email} />
<button>Войти</button>
</form>
);
}
В консоли при этом лежит подсказка, которую кандидаты почему-то не читают:
Warning: You provided a `value` prop to a form field without an `onChange` handler.
This will render a read-only field. If the field should be mutable use `defaultValue`.
Otherwise, set either `onChange` or `readOnly`.
Разбор по шагам. Браузер честно вставляет символ в DOM-узел — на уровне DOM значение поля меняется. Дальше React обрабатывает событие, но setEmail никто не вызвал, состояние прежнее, повода для нового рендера нет. И тут срабатывает главное: React принудительно возвращает значение DOM-узла к тому, что указано в value. Символ исчезает. Поле не «сломано» — оно работает ровно так, как вы попросили: «всегда показывай email», а email — пустая строка.
Три законных варианта
Warning прямо перечисляет выходы, и на собеседовании стоит назвать все три.
1. Контролируемое поле: value + onChange
function LoginForm() {
const [email, setEmail] = useState('');
const valid = email.includes('@');
function handleSubmit(e) {
e.preventDefault();
send(email); // значение уже в состоянии, читать DOM не нужно
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{/* мгновенная валидация — она возможна только потому, что значение в состоянии */}
<button disabled={!valid}>Войти</button>
</form>
);
}
Цикл замкнулся: ввод → onChange → setEmail → новый рендер → новое value в DOM. Состояние React — единственный источник правды, DOM лишь отражает его.
2. Только чтение: readOnly
Если поле и правда должно быть нередактируемым (сгенерированный промокод, ID заказа), добавьте readOnly — предупреждение уйдёт, а намерение станет явным.
3. Неконтролируемое поле: defaultValue + ref
function LoginForm() {
const emailRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
// значение живёт в DOM, забираем его один раз — в момент отправки
send(emailRef.current.value);
}
return (
<form onSubmit={handleSubmit}>
<input type="email" defaultValue="" ref={emailRef} />
<button>Войти</button>
</form>
);
}
Обратите внимание: на каждое нажатие клавиши здесь не происходит ни одного рендера. Именно на этом построена популярная библиотека react-hook-form — и это осознанный выбор, а не костыль.
Отсюда и главный практический критерий, который интервьюер хочет от вас услышать. Спросите себя: нужно ли приложению реагировать на каждый введённый символ? Если да — счётчик оставшихся символов, подсказки поиска, кнопка, которая разблокируется по мере заполнения, — состояние обязано быть в React, иначе реагировать просто не на что. Если значение нужно ровно один раз, в момент отправки, то тридцать лишних рендеров на каждое слово — это плата ни за что.
Когда что выбирать
| Ситуация | Выбор |
Валидация на лету, disabled у кнопки, счётчик символов | Контролируемое |
| Зависимые поля (выбрал страну — обновился список городов) | Контролируемое |
| Маска ввода, автоформатирование телефона, приведение к верхнему регистру | Контролируемое |
| Большая форма на 40 полей, ввод «подтормаживает» | Неконтролируемое или локальное состояние поля |
| Интеграция с не-React кодом, значение нужно только при submit | Неконтролируемое |
<input type="file"> | Только неконтролируемое: задать value программно браузер не даст из соображений безопасности |
Ловушка: поле, которое меняет тип на ходу
Самая частая ошибка в реальном коде — undefined в value.
const [user, setUser] = useState({}); // профиль ещё не загружен, user.name === undefined
// value === undefined -> React считает поле НЕконтролируемым
<input value={user.name} onChange={handleChange} />
// профиль пришёл, name появился -> поле внезапно стало контролируемым:
// Warning: A component is changing an uncontrolled input to be controlled.
// Лечится одним символом: значение никогда не должно быть undefined
<input value={user.name ?? ''} onChange={handleChange} />
Как это работает под капотом
React не вешает onChange на каждый <input> — он ставит делегированные слушатели на корень приложения и ловит нативные события всплытием. Кстати, реактовский onChange — это на самом деле нативное событие input: он срабатывает на каждый символ, а не при потере фокуса, как одноимённое событие в чистом DOM. Это ещё один любимый вопрос интервьюеров.
После обработки события React делает то, что называется восстановлением контролируемого состояния: если у узла есть проп value, React сверяет его с текущим значением DOM-узла и, если они разошлись, записывает значение из пропсов обратно в DOM. Отсюда и «замороженное» поле из первой задачи, и главный тезис: контролируемое поле — это поле, которому запрещено иметь собственное мнение.
С неконтролируемым всё наоборот: defaultValue React пишет в DOM один раз при монтировании и дальше в значение не вмешивается. Отсюда типичный баг: меняете defaultValue при обновлении пропсов и удивляетесь, что в поле ничего не поменялось. Чтобы «перезалить» такое поле новыми данными, ему меняют key — React размонтирует старый узел и смонтирует новый.
Частые ошибки на собеседовании
- «Неконтролируемые компоненты — антипаттерн». Неверно: это штатный инструмент, на нём стоят самые быстрые библиотеки форм.
- Путают
valueиdefaultValue, а потом передают оба сразу — React ругается. - Не могут объяснить причину warning'а. Отвечают «React так требует» вместо «React возвращает DOM-значение к пропсу, поэтому ввод не применяется».
- Забывают про
?? ''и получают перескок uncontrolled → controlled на асинхронно загруженных данных. - «Контролируемая форма тормозит — значит, React медленный». Причина конкретнее: каждый символ вызывает рендер всего поддерева, где живёт состояние. Лечится опусканием состояния ниже, дебаунсом или переходом на
ref. - Пытаются сделать
type="file"контролируемым. Браузер это запрещает.
Итоги-шпаргалка
- Контролируемое поле:
value+onChange, источник правды — состояние React. - Неконтролируемое поле:
defaultValue+ref, источник правды — DOM; рендеров на ввод нет. valueбезonChange= поле только для чтения плюс warning. Выходы:onChange,readOnlyилиdefaultValue.value={x ?? ''}— страховка от прыжка uncontrolled → controlled.- Нужна реакция на каждый символ — контролируемое. Нужны скорость и простота, значение только при submit — неконтролируемое.
onChangeв React = нативныйinput: срабатывает на каждый символ.<input type="file">всегда неконтролируемый.