Контролируемые и неконтролируемые компоненты

«Я передал в поле 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>
  );
}

Цикл замкнулся: ввод → onChangesetEmail → новый рендер → новое 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"> всегда неконтролируемый.
Проверьте себя
1. Что произойдёт с полем <input value={email} /> без обработчика onChange?
AПоле будет работать как обычное: React сам обновит состояние
BReact выведет предупреждение, а поле станет фактически доступным только для чтения — введённые символы не применятся
CReact автоматически подставит onChange, вызывающий setEmail
DКомпонент упадёт с ошибкой при монтировании
2. Как правильно избавиться от предупреждения «A component is changing an uncontrolled input to be controlled», когда данные приходят с сервера?
AОбернуть input в React.memo
BГарантировать, что value никогда не будет undefined: value={user.name ?? ''}
CПередать одновременно и value, и defaultValue
DДобавить полю уникальный key