Controlled и uncontrolled инпуты: кто владеет значением и сколько это стоит
Controlled-инпут хранит значение в state React и ререндерится на каждый символ; uncontrolled оставляет его в DOM и читает через ref или FormData на submit. Каждый режим выигрывает в своих формах — а value без onChange или смена режима на лету дают реальные продакшен-баги.
Тикеты в поддержку пошли во вторник в 9:14: «поле для промокода сломано — печатаю, и ничего не появляется». Команда чекаута не могла воспроизвести баг, потому что в dev-сборке сигнал тонул в шуме: консоль кричала уже два спринта — You provided a value prop to a form field without an onChange handler. This will render a read-only field. Во время рефакторинга кто-то поднял промокод в Redux, чтобы показывать его в сводке заказа, привязал value к стору — и удалил локальный onChange как «мёртвый код», потому что в ручном тесте сводка всё ещё обновлялась (тестировали вставкой через автозаполнение браузера, которое заодно тоже сломали). Инпут теперь контролировался значением, которое никто никогда не обновлял: React послушно сбрасывал DOM обратно к протухшему значению из стора после каждого нажатия клавиши. Три строки кода, одиннадцать дней в продакшене, измеримая просадка применённых промокодов. У этого класса багов есть имя — полуконтролируемый инпут — и существует он потому, что у React две модели владения состоянием формы, и компонент тихо деградирует, когда вы не выбираете ни одну.
Два владельца, один инпут
У каждого поля формы ровно один источник истины, и React заставляет выбрать, какой именно. В controlled-инпуте значением владеет state React: проп value прибивает то, что показывает DOM, а onChange — единственная дверь, через которую нажатие клавиши становится видимым: событие срабатывает, вы вызываете setState, React ререндерит, и новое значение стекает обратно в DOM. DOM-инпут низведён до тупого дисплея вашего состояния. В uncontrolled-инпуте значением владеет DOM — ровно как на обычной HTML-странице: браузер сам отслеживает ввод, React не слышит отдельных нажатий, а результат вы читаете тогда, когда он нужен — через ref или весь разом через new FormData(form) в обработчике submit. defaultValue (не value) задаёт начальный текст, не претендуя на владение.
Различие не стилистическое; оно решает, когда выполняется ваш код. Controlled означает ререндер компонента на каждый символ — это входной билет. Uncontrolled означает один рендер, а ваш код выполняется в момент submit.
function CouponControlled() {
const [code, setCode] = useState("");
// Ре-рендер на каждое нажатие; можно валидировать, трансформировать, блокировать вживую.
return (
<input
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
/>
);
}
function CheckoutUncontrolled() {
// Рендерится один раз; DOM хранит значения до момента submit.
function handleSubmit(e) {
e.preventDefault();
const data = new FormData(e.currentTarget);
submitOrder(data.get("coupon"), data.get("email"));
}
return (
<form onSubmit={handleSubmit}>
<input name="coupon" defaultValue="" />
<input name="email" type="email" />
<button>Pay</button>
</form>
);
}Что controlled покупает и какой счёт выставляет
Выбирайте controlled, когда UI должен реагировать на значение по мере ввода: живые сообщения валидации, счётчик символов, форматирование на лету (номер карты группами по четыре), фильтрация списка на каждый символ, активация кнопки submit только при валидной форме, синхронизация двух виджетов одним значением. Ничего из этого невозможно, если React узнаёт значение только на submit.
Счёт приходит в виде работы рендера. Один controlled-инпут, ререндерящий собственный маленький компонент, — ничто: рендер — это вызов функции, производящий элементы, сильно меньше миллисекунды. Провальный режим структурный: команды поднимают состояние всех полей в один объект на вершине формы из 60 полей, и каждый символ в любом поле ререндерит всё дерево формы. При 5–15 мс на полный рендер формы это ещё влезает в кадр; добавьте пару дорогих дочерних компонентов — и вы на 40–80 мс на символ, и набор текста заметно заикается, потому что каждое нажатие теперь запускает работу размером с layout. Лечение тоже структурное: держите состояние поля в наименьшем компоненте, которому оно нужно, мемоизируйте тяжёлых соседей или перестаньте контролировать поля, которые никто не читает до submit.
Выбирайте uncontrolled, когда форма «сабмитной» формы: ничто не зависит от промежуточных значений, нужна минимальная цена ререндеров, вы интегрируете не-React-виджет, который сам мутирует DOM, или работаете с файлами — <input type="file" /> всегда uncontrolled, потому что его значение может установить только пользователь, никогда код.
▸Почему это работает
Зачем React вообще навязывает выбор, если обычный HTML просто позволяет DOM хранить значение? Потому что базовый контракт React — UI есть функция состояния, а DOM-узел, мутирующий сам себя на каждый символ, — это состояние, живущее вне этой функции. Controlled-инпуты восстанавливают контракт, низводя DOM до проекции состояния React. Uncontrolled-инпуты сохраняют нативное поведение DOM и принимают, что этот кусок состояния невидим для рендеринга. Легитимны оба; нелегитимен инпут, где два владельца дерутся, — а это и есть полуконтролируемый баг.
Полуконтролируемый инпут: как режимы портят друг друга
Почти все баги форм в этой области — два сценария. Первый: value без onChange. Инпут становится контролируемым константой. Пользователь печатает, DOM на мгновение показывает символ, React ререндерит (или просто сбрасывает узел) обратно к прибитому значению — поле съедает нажатия. React один раз предупреждает в консоли и предлагает легальные выходы: добавить onChange, поставить readOnly, если поле действительно только для чтения, или использовать defaultValue, если вы хотели лишь задать начальное значение.
Второй: смена режима в течение жизни компонента. Инпут монтируется uncontrolled (value равен undefined — например, данные профиля ещё не загрузились), а позже получает строку, когда fetch разрешился. React пишет: A component is changing an uncontrolled input to be controlled. Опасность не в тексте предупреждения, а в том, что всё набранное пользователем за uncontrolled-окно молча уничтожается, когда проп value забирает владение. Обратное переключение (controlled → uncontrolled, value становится undefined) бросает поле с замороженным последним значением. Правило: инпут controlled или uncontrolled на всю свою жизнь. Если начальное состояние может отсутствовать — инициализируйте "", никогда undefined, или не рендерите инпут, пока данных нет.
Форма профиля инициализируется через const [name, setName] = useState(user.name), где user.name равен undefined до завершения fetch, а затем становится строкой. У инпута value={name} и корректный onChange. Что на самом деле идёт не так?
Страховая форма на 60 полей держит все поля в одном объекте useState в корне формы; ввод в любое поле заметно лагает. Каков механизм лага?
- 01Проследите одно нажатие клавиши через controlled-инпут и через uncontrolled — какой код выполняется, когда, и кто держит значение на каждом шаге?
- 02Назовите два полуконтролируемых сценария отказа, их видимые пользователю симптомы и правило, предотвращающее оба.
React даёт полю формы двух возможных владельцев и требует выбрать одного. Controlled: проп value прибивает DOM к состоянию React, onChange — единственный способ сделать нажатие видимым, и петля «нажатие → setState → ререндер» крутится на каждый символ. Это покупает всё, чему нужно живое значение — валидацию по мере ввода, форматирование, счётчики символов, зависимые кнопки — и выставляет счёт в виде ререндера на нажатие: пренебрежимого для одного маленького компонента и очень реального, когда форма из 60 полей держит всё состояние в корне и ререндерит всё дерево посимвольно; лечение — колокация состояния полей, мемоизация тяжёлых соседей или отказ от контроля над тем, что никто не читает. Uncontrolled: значением владеет DOM с нативным поведением браузера, React ничего не выполняет во время набора, а результат читается один раз на submit через ref или FormData; defaultValue задаёт значение, не владея им, файловые инпуты всегда uncontrolled. Два продакшен-класса багов — это драки за владение: value без onChange контролирует инпут константой, и поле съедает нажатия (консольное предупреждение React называет это прямо), а value, скачущий между undefined и строкой, переключает режим инпута на лету, уничтожая всё набранное в промежутке. Инициализируйте controlled-состояние пустой строкой, никогда undefined, и держите один режим всю жизнь инпута. Теперь, когда встретишь поле, молча поглощающее нажатия, или предупреждение о переключении из uncontrolled в controlled, — ты точно знаешь, какое правило владения нарушено и что однострочный фикс исправит.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.