Управляемые и неуправляемые формы
Управляемые поля отдают значение React (value + onChange) ради живой валидации или зависящего от значения UI; неуправляемые отдают значение DOM, читаются раз через FormData — меньше перерисовок и кода. По умолчанию — неуправляемые для простых форм только с отправкой.
Рефлекс, выработанный на треке React, — подключать каждое поле как value={x} onChange={...}. Это ощущается как «правильный React-путь»: состояние внутрь, состояние наружу, полный контроль. И вот форма регистрации получает восемь вызовов useState и восемь обработчиков onChange, а каждое нажатие клавиши в любом поле перерисовывает весь компонент формы — включая те семь полей, которых пользователь не касается.
Бо́льшая часть этого контроля не покупает ничего. У формы есть один значимый момент — отправка, — и значения нужны только тогда. На виду прячется более экономный вариант по умолчанию: пусть DOM держит текст, а ты прочитаешь его один раз при отправке формы. Знать, какие именно поля действительно требуют, чтобы React владел их значением во время ввода, — это и есть senior-решение, о котором этот урок.
После этого урока ты можешь отличить управляемое поле (React владеет значением через value + onChange, перерисовываясь на каждое нажатие) от неуправляемого (значением владеет DOM, читается при отправке через FormData или ref); выбирать управляемое только тогда, когда значение движет другим UI или требует живой валидации; по умолчанию брать неуправляемое + FormData для простых форм только с отправкой; прочитать action формы React 19, которому FormData достаётся даром; и назвать режим отказа — управление каждым полем по рефлексу и перерисовку большой формы на каждое нажатие.
Управляемое означает, что React — единственный источник истины для значения: value плюс onChange и перерисовка на каждое нажатие. Поле показывает ровно то, что говорит состояние; ввод вызывает onChange, ты обновляешь состояние, React перерисовывает, поле показывает новое значение. Этот цикл туда-обратно — и есть смысл: поскольку состояние отражает поле на каждой клавише, ты можешь реагировать на значение пока пользователь печатает — валидировать его, преобразовывать, двигать им другой UI.
function ControlledEmail() {
const [email, setEmail] = useState("");
const invalid = email.length > 0 && !email.includes("@");
return (
<>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
{invalid && <p role="alert">Email needs an @</p>}
</>
);
}Цена явная: каждое нажатие — это обновление состояния и рендер. Для одного поля это ничто. Для формы из 20 полей, где всё состояние сидит в одном компоненте, каждая клавиша перерисовывает все 20.
Неуправляемое означает, что значением владеет DOM — ты его не отслеживаешь, а читаешь один раз при отправке. Никакого value, никакого onChange, никакого состояния, никакого рендера на каждое нажатие. Браузер делает то, что браузеры делали всегда: держит текст в поле. Начальное значение задаёшь через defaultValue (не value), а финальное забираешь, когда оно нужно — при отправке.
function UncontrolledEmail() {
const ref = useRef<HTMLInputElement>(null);
function submit() {
console.log(ref.current?.value); // read once, on demand
}
return <input ref={ref} defaultValue="" name="email" />;
}Ввод здесь вызывает ноль рендеров React — компонент рендерится один раз и остаётся на месте, пока пользователь заполняет всю форму. Это более экономный вариант по умолчанию, и для большинства форм он же и меньше кода: ни состояния, ни обработчика на поле.
Для форм только с отправкой пропусти и реф — прочитай все поля сразу через FormData. ref на каждое поле нормален для одного-двух полей, но это всё ещё бойлерплейт, растущий линейно. Браузер уже собирает за тебя каждое именованное поле. Дай каждому полю name, повесь один onSubmit на <form> и собери FormData из элемента формы. Один обработчик, любое число полей, никакой обвязки на каждое поле.
function SignupForm() {
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
const email = data.get("email"); // typed values, read once
const password = data.get("password");
// ...send to the server
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" defaultValue="" />
<input name="password" type="password" />
<button>Sign up</button>
</form>
);
}Десять полей стоят столько же, сколько два: ни лишнего состояния, ни лишних рефов, ни лишних рендеров. Это паттерн, к которому простая форма должна тянуться первым.
React 19 делает неуправляемый вариант по умолчанию полноправным: action формы получает FormData напрямую. Вместо onSubmit + preventDefault + ручного new FormData ты передаёшь функцию в проп action формы, и React вызывает её с заполненным FormData. Соедини это с useActionState — и получишь состояние ожидания и возвращённый результат, не управляя ни единым полем: фреймворк построен вокруг формы «неуправляемое-плюс-FormData», а не управляемой.
"use client";
import { useActionState } from "react";
async function signup(_prev: string | null, formData: FormData) {
const email = formData.get("email");
// call the server, return an error string or null
return email ? null : "Email is required";
}
function SignupForm() {
const [error, action, pending] = useActionState(signup, null);
return (
<form action={action}>
<input name="email" type="email" />
<button disabled={pending}>{pending ? "..." : "Sign up"}</button>
{error && <p role="alert">{error}</p>}
</form>
);
}Заметь: ни одно из этих полей не управляемо. Нужное значение приходит в formData ровно в тот момент, когда оно тебе нужно.
Форма профиля, управляемая по рефлексу, а затем исправленная. Вариант «до» управляет всеми пятью полями в одном компоненте — стандартная ошибка senior-трека.
// before: every keystroke re-renders all five fields
function ProfileForm() {
const [name, setName] = useState("");
const [bio, setBio] = useState("");
const [city, setCity] = useState("");
const [site, setSite] = useState("");
const [phone, setPhone] = useState("");
return (
<form onSubmit={/* read 5 state vars */ () => {}}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<textarea value={bio} onChange={(e) => setBio(e.target.value)} />
<input value={city} onChange={(e) => setCity(e.target.value)} />
<input value={site} onChange={(e) => setSite(e.target.value)} />
<input value={phone} onChange={(e) => setPhone(e.target.value)} />
<button>Save</button>
</form>
);
}Ни одно из этих значений не движет другим UI и не требует живой валидации. Они читаются только при сохранении — так что контроль здесь чистые накладные расходы: пять useState, пять обработчиков, полная перерисовка на каждую клавишу в любом поле. Исправление — неуправляемое + FormData.
// after: zero state, zero per-keystroke renders
function ProfileForm() {
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const d = new FormData(e.currentTarget);
save({
name: d.get("name"),
bio: d.get("bio"),
city: d.get("city"),
site: d.get("site"),
phone: d.get("phone"),
});
}
return (
<form onSubmit={handleSubmit}>
<input name="name" defaultValue="" />
<textarea name="bio" defaultValue="" />
<input name="city" defaultValue="" />
<input name="site" defaultValue="" />
<input name="phone" defaultValue="" />
<button>Save</button>
</form>
);
}То же поведение, меньше строк, и компонент рендерится ровно один раз за всю сессию редактирования. Теперь представь, что одному полю действительно нужна живая обратная связь — имя пользователя, показывающее «занято / свободно» по мере ввода. Ты сделаешь управляемым только это поле и оставишь остальные четыре неуправляемыми. Это и есть senior-позиция: неуправляемое по умолчанию, управляемое точечно там, где значение действительно нужно во время ввода.
▸Почему это работает
Почему перерисовка на каждое нажатие обычно нормальна, пока не перестаёт ею быть? Рендеры React по отдельности дёшевы, и управляемое поле на маленькой форме незаметно. Цена проявляется, когда всё состояние живёт в одном большом компоненте: один setState перерисовывает этот компонент и всё его поддерево, так что одно нажатие может перерисовать десятки полей плюс любых дорогих соседей (живой предпросмотр, график, тяжёлый список). Управляемую форму можно спасти, колоцируя состояние каждого поля в его собственном дочернем компоненте, чтобы нажатие перерисовывало только это поле, — но если значение во время ввода тебе вовсе не нужно, неуправляемое снимает весь вопрос.
▸Частая ошибка
Режим отказа — управление каждым полем по рефлексу. На форме, которую ты строишь сегодня, это выглядит безобидно, а потом кто-то добавляет панель живого предпросмотра или форма вырастает до 30 полей — и вдруг каждое нажатие дёргается, потому что на каждую клавишу идёт полная перерисовка. Признак — компонент формы, набитый парами useState/onChange, чьи значения читаются только внутри onSubmit. Если значение читается только при отправке, оно никогда не должно было быть состоянием. Тянись к управляемому, когда значение движет другим UI или требует валидации по мере ввода, — иначе пусть DOM его держит.
Ты строишь форму обратной связи: имя, email, сообщение — значения читаются только когда пользователь нажимает «Отправить», без живой валидации и без чего-либо ещё на странице, зависящего от них. Каков senior-вариант по умолчанию?
Управляемое поле делает React источником истины — value + onChange, перерисовка на каждое нажатие, — что нужно только тогда, когда значение движет другим UI или требует живой валидации по мере ввода. Неуправляемое поле отдаёт значение DOM: задай defaultValue, затем прочитай его раз при отправке через ref или, лучше для многополевых форм, одним FormData по именованным полям. React 19 опирается на это — action формы получает FormData напрямую и сочетается с useActionState ради состояния ожидания и результата, всё это без управления единым полем. Senior-вариант по умолчанию — неуправляемое + FormData для простых форм только с отправкой (меньше кода, куда меньше рендеров) и управляемое точечно для одного-двух полей, которым значение действительно нужно во время ввода. Режим отказа — обратный рефлекс, управлять всем, — он перерисовывает большую форму на каждое нажатие ради значений, которые ты читаешь только при отправке.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.