Формуляри в компонент
Накратко: Контролираното поле в React държи стойността си в състоянието (value и onChange), така че състоянието е единственият източник за проверката и изпращането.
Проблем
Написано, но празно
Купувачът попълва поръчката и натиска „Поръчай“. Сървърът отговаря, че името е задължително. А полето „Име“ показва името.
Конзола
Напиши име, имейл и адрес, отметни условията и натисни „Поръчай“. Полето пази „Мария Иванова“, но в заявката заминава name: ''. Имейлът и адресът имат value и onChange, а името няма. Кодът изпраща състоянието, а то не знае какво е написано в полето.
Идеята
Една истина: състоянието
В урока за DOM формулярът пазеше текста сам и кодът го четеше при изпращане. В компонент има и второ място: състоянието. Ако двете не са свързани, всяко казва своето, както в примера.
Решението е полето да няма своя истина. То показва стойността от състоянието и при всеки клавиш казва на състоянието новия текст. Такова поле се казва контролирано (controlled input).
const [name, setName] = useState('');
<input id="name" value={name} onChange={(e) => setName(e.target.value)} />value={name} е посоката от състоянието към полето. onChange е посоката обратно: e.target.value е текстът на полето в момента на събитието. В React onChange идва при всеки клавиш, не само когато полето загуби фокус.
Другите видове полета са същото, с друго свойство:
<select value={payment} onChange={(e) => setPayment(e.target.value)}>…</select><input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />Сега състоянието е единственият източник: каквото е в него, това е в полето, и обратното. Бутонът може да се изключи по състоянието (!agree), а заявката изпраща точно каквото вижда купувачът.
Виж го
Виж кръга
Една буква минава целия път: поле, събитие, състояние, ново рисуване и пак поле. После същото поле без връзка.
function CheckoutForm() { const [name, setName] = useState('');
return ( <form onSubmit={submit}> <label htmlFor="name">Име</label> <input id="name" value={name} onChange={(e) => setName(e.target.value)} /> </form> );}Стъпка по стъпка, с думи
- Формулярът пази името в състоянието: name = ''. Полето на страницата показва каквото казва състоянието, а то казва: празно.
- Купувачът натиска М. Браузърът слага буквата в полето и пуска събитието input.
- onChange хваща събитието. e.target.value е текстът на полето сега: „М“.
- setName("М") сменя състоянието. Това е единственото място, където живее името.
- Състоянието се смени и React вика CheckoutForm наново. Тя връща полето с value = name: „М“.
- Полето получава стойността си от състоянието. Буквата е същата, но сега състоянието решава какво показва полето.
- Всеки клавиш минава целия кръг: поле, събитие, състояние, ново рисуване, поле. Един източник на истината: състоянието.
- Сега същото поле без value и onChange. Полето работи, но никой не го слуша и никой не му дава стойност.
- Купувачът пише „Мария“. Буквите остават в DOM-а. Състоянието още казва: name = ''.
- При изпращане кодът праща състоянието: { name: '' }. Сървърът отговаря „Името е задължително“, а полето показва „Мария“.
- Правилото: всяко поле има value от състоянието и onChange обратно към него. Тогава полето и състоянието не могат да се разминат.
Кръгът изглежда като излишна обиколка: буквата и без него стои в полето. Но заради него състоянието и полето не могат да се разминат.
Пробвай
Вържи полетата
Полето „Име“, падащият списък „Плащане“ и отметката „Приемам условията“ не са вързани за състоянието. Вържи и трите. Бутонът „Поръчай“ е изключен, докато условията не са приети. Проверката пише име, имейл и адрес, избира „С карта при доставка“, отметва и маха отметката и после поръчва. Очаква „Поръчка № 61 е приета.“ и заявка с написаното име и payment: 'card'.
Конзола
Решение
App.jsximport CheckoutForm from './CheckoutForm.jsx';
export default function App() {
return (
<main>
<h1>Поръчка</h1>
<CheckoutForm />
</main>
);
}
CheckoutForm.jsximport { useState } from 'react';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
export default function CheckoutForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [payment, setPayment] = useState('cash');
const [agree, setAgree] = useState(false);
const [orderId, setOrderId] = useState(null);
const [problem, setProblem] = useState('');
async function submit(e) {
e.preventDefault();
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, address, payment, items: ITEMS }),
});
const data = await res.json();
if (res.ok) setOrderId(data.id);
else setProblem(Object.values(data.errors).join(' '));
}
if (orderId) return <p role="status">Поръчка № {orderId} е приета.</p>;
return (
<form onSubmit={submit} noValidate>
<p>В кошницата: Бял хляб × 2, Прясно мляко × 1</p>
<label htmlFor="name">Име</label>
<input id="name" value={name} onChange={(e) => setName(e.target.value)} />
<label htmlFor="email">Имейл</label>
<input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<label htmlFor="address">Адрес</label>
<input id="address" value={address} onChange={(e) => setAddress(e.target.value)} />
<label htmlFor="payment">Плащане</label>
<select id="payment" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="cash">Наложен платеж</option>
<option value="card">С карта при доставка</option>
</select>
<label>
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
Приемам условията
</label>
{problem && <p role="alert">{problem}</p>}
<button type="submit" disabled={!agree}>
Поръчай
</button>
</form>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Името получава value={name} и onChange={(e) => setName(e.target.value)}. Списъкът получава value={payment} и onChange със setPayment. Отметката получава checked={agree} и onChange={(e) => setAgree(e.target.checked)}.
Проверката хваща value={name} без onChange: полето става само за четене и конзолата казва You provided a `value` prop to a form field without an `onChange` handler. Хваща и setAgree(e.target.value): стойността на отметката е "on" при всеки клик, затова веднъж отметната, тя не се маха от състоянието.
Пробвай
Попълни
Какво стои на празното място, за да е вързана отметката?
const [agree, setAgree] = useState(false);
<input
type="checkbox"
checked={agree}
onChange={(e) => setAgree(e.target.)}
/>checked: отметката казва дали е отметната с e.target.checked, true или false. e.target.value е текстът, който заминава с формуляра, и не се сменя при клик.Пробвай
Намери грешката
Избери реда с грешката.
value={name} без onChange. Полето показва name, но никой не сменя name, затова React го връща към празно след всеки клавиш. Трябва и onChange={(e) => setName(e.target.value)}.Идеята
Проверка до полето
Сървърът проверява всяка поръчка. Купувачът обаче не трябва да чака отговор, за да разбере, че е забравил адреса. Затова формулярът проверява и сам, преди да изпрати.
- Кога. При натискане на „Поръчай“. Ако има грешки, заявка не тръгва.
- Какво. Функция
check()връща обект с грешките по поле:{ name: 'Напиши името си', email: … }. Празен обект значи „всичко е наред“. Той се пази в състояниетоerrors. - Къде. До полето, а не в общ списък горе. Купувачът вижда кое поле да поправи.
- За всеки. Екранният четец не вижда, че текстът е червен. Затова полето получава
aria-invalid="true"(четецът казва „невалидно“), а съобщението получаваid, към който полето сочи сaria-describedby. Когато фокусът дойде на полето, четецът прочита и съобщението.
<input id="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!errors.email} aria-describedby={errors.email ? 'email-error' : undefined}/>{errors.email && <p id="email-error" className="error">{errors.email}</p>}Формулярът е с novalidate (в JSX noValidate). Без него браузърът спира изпращането със свои балончета, преди да стигне до твоя код, и двете проверки си пречат.
Сървърът проверява пак. Проверката в браузъра е за удобството на купувача. Тя не пази магазина: кодът на страницата е при купувача и заявка може да дойде и без нея. Защо е така има в урока „Недоверен вход“.
Пробвай
Провери преди изпращане
Добави проверка преди изпращане. Празно име дава „Напиши името си“, имейл без @ дава „Имейлът трябва да съдържа @“, празен адрес дава „Напиши адрес за доставка“. Всяко съобщение е до полето си, в елемент с id name-error, email-error и address-error, а полето има aria-invalid=true и aria-describedby към него. С грешки заявка не тръгва. Проверката отметва условията, поръчва празен формуляр, после с имейл без @ и накрая с всичко вярно.
Конзола
Решение
App.jsximport CheckoutForm from './CheckoutForm.jsx';
export default function App() {
return (
<main>
<h1>Поръчка</h1>
<CheckoutForm />
</main>
);
}
CheckoutForm.jsximport { useState } from 'react';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
export default function CheckoutForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [payment, setPayment] = useState('cash');
const [agree, setAgree] = useState(false);
const [orderId, setOrderId] = useState(null);
const [problem, setProblem] = useState('');
const [errors, setErrors] = useState({});
function check() {
const found = {};
if (!name.trim()) found.name = 'Напиши името си';
if (!email.includes('@')) found.email = 'Имейлът трябва да съдържа @';
if (!address.trim()) found.address = 'Напиши адрес за доставка';
return found;
}
async function submit(e) {
e.preventDefault();
const found = check();
setErrors(found);
if (Object.keys(found).length > 0) return;
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, address, payment, items: ITEMS }),
});
const data = await res.json();
if (res.ok) setOrderId(data.id);
else setProblem(Object.values(data.errors).join(' '));
}
if (orderId) return <p role="status">Поръчка № {orderId} е приета.</p>;
return (
<form onSubmit={submit} noValidate>
<p>В кошницата: Бял хляб × 2, Прясно мляко × 1</p>
<label htmlFor="name">Име</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && <p id="name-error" className="error">{errors.name}</p>}
<label htmlFor="email">Имейл</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <p id="email-error" className="error">{errors.email}</p>}
<label htmlFor="address">Адрес</label>
<input
id="address"
value={address}
onChange={(e) => setAddress(e.target.value)}
aria-invalid={!!errors.address}
aria-describedby={errors.address ? 'address-error' : undefined}
/>
{errors.address && <p id="address-error" className="error">{errors.address}</p>}
<label htmlFor="payment">Плащане</label>
<select id="payment" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="cash">Наложен платеж</option>
<option value="card">С карта при доставка</option>
</select>
<label>
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
Приемам условията
</label>
{problem && <p role="alert">{problem}</p>}
<button type="submit" disabled={!agree}>
Поръчай
</button>
</form>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Добави const [errors, setErrors] = useState({}) и функция check(), която връща обект с грешките. В submit първо const found = check(); setErrors(found);, а ако found има ключове, return преди fetch. До всяко поле: aria-invalid={!!errors.name}, aria-describedby={errors.name ? 'name-error' : undefined} и {errors.name && <p id="name-error">…</p>}.
Проверката хваща съобщение без aria-invalid и aria-describedby: на екрана изглежда същото, но за екранния четец полето е наред. Хваща и съобщения, след които заявката тръгва все пак: тогава след малко се появява и отговорът на сървъра.
Идеята
Изпращане и отговорът на сървъра
Заявката отнема време, а купувачът не знае дали кликът е стигнал. Ако кликне пак, заминава втора поръчка. Затова състоянието има и sending:
- Преди
fetchsendingставаtrue. Бутонът еdisabled, а до него пише „Изпраща се…“. - След отговора
sendingставаfalse, каквото и да е дошло. - 201: в тялото е
{ id }. Формулярът се сменя със „Поръчка № 61 е приета.“ - 400: в тялото е
{ errors: { name, email, address, items } }, по едно съобщение на поле. Те отиват в същотоerrors, затова се показват на същите места до полетата.itemsняма поле: показва се до реда с кошницата.
setSending(true);const res = await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email, address, payment, items: ITEMS }),});const data = await res.json();setSending(false);if (res.ok) setOrderId(data.id);else setErrors(data.errors);Проверката в браузъра е по-проста от тази на сървъра: тя търси само „@“. Имейлът maria@shop я минава, а сървърът го връща с „Имейлът не е валиден“. Затова формулярът трябва да може да покаже и грешка, за която сам не е знаел.
По-точно, за любопитните
Неконтролирани полета. Поле без value пази текста само. Стойността се взима при изпращане: от FormData или с ref. defaultValue дава само началния текст. Това е по-малко код, но състоянието не знае какво пише купувачът, докато не изпрати. Затова не можеш да изключиш бутона по полето или да провериш, докато пише.
От неконтролирано към контролирано. Ако value е undefined в началото (useState() без начална стойност) и после стане текст, React казва в конзолата A component is changing an uncontrolled input to be controlled. Началната стойност на текстово поле е '', не undefined.
Формуляри с действие в React 19. <form action={fn}> вика fn с FormData на формуляра и след това изчиства неконтролираните полета. Има и куки около него (useActionState, useFormStatus). Това е друг стил, по-близо до формуляра от урока за HTML. Контролираните полета остават най-прекият път, когато интерфейсът трябва да реагира, докато купувачът пише.
Обект вместо пет състояния. Може и const [form, setForm] = useState({ name: '', email: '', … }) с една функция onChange, която чете e.target.name. Тогава новият обект е копие: setForm({ ...form, [e.target.name]: e.target.value }).
Кога да проверяваш. При изпращане е най-простото. Проверка при всеки клавиш показва грешки, преди купувачът да е довършил („имейлът е невалиден“ след първата буква). Честото решение е: първа проверка при изпращане или при напускане на полето (blur), а после при всеки клавиш, за да изчезне грешката, щом е поправена.
Фокус при грешка. След неуспешно изпращане е удобно фокусът да отиде на първото поле с грешка. Така клавиатурата и екранният четец стигат направо до проблема.
Проверката не може да види кода. Задачите тук проверяват поведението: какво се вижда, какво е изключено, какво заминава. Те не виждат дали проверката е в отделна функция или как са кръстени променливите.
Упражнение
Поръчката докрай
Сега формулярът проверява в браузъра, но не показва, че изпраща, а грешките на сървъра събира в един абзац. Направи го като истинска поръчка: докато заявката върви, бутонът „Поръчай“ е изключен и има текст „Изпраща се…“ (с role=status). При отговор 400 грешките от data.errors отиват до полетата, на същите места като твоите (email-error и aria-invalid на имейла), а грешката за items до реда с кошницата. Проверката пише имейл maria@shop: той минава твоята проверка, но сървърът го връща с „Имейлът не е валиден“. После добавя .bg и поръчката минава: „Поръчка № 61 е приета.“
Конзола
Решение
App.jsximport CheckoutForm from './CheckoutForm.jsx';
export default function App() {
return (
<main>
<h1>Поръчка</h1>
<CheckoutForm />
</main>
);
}
CheckoutForm.jsximport { useState } from 'react';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
export default function CheckoutForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [payment, setPayment] = useState('cash');
const [agree, setAgree] = useState(false);
const [orderId, setOrderId] = useState(null);
const [errors, setErrors] = useState({});
const [sending, setSending] = useState(false);
function check() {
const found = {};
if (!name.trim()) found.name = 'Напиши името си';
if (!email.includes('@')) found.email = 'Имейлът трябва да съдържа @';
if (!address.trim()) found.address = 'Напиши адрес за доставка';
return found;
}
async function submit(e) {
e.preventDefault();
const found = check();
setErrors(found);
if (Object.keys(found).length > 0) return;
setSending(true);
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, address, payment, items: ITEMS }),
});
const data = await res.json();
setSending(false);
if (res.ok) setOrderId(data.id);
else setErrors(data.errors);
}
if (orderId) return <p role="status">Поръчка № {orderId} е приета.</p>;
return (
<form onSubmit={submit} noValidate>
<p>В кошницата: Бял хляб × 2, Прясно мляко × 1</p>
{errors.items && <p role="alert">{errors.items}</p>}
<label htmlFor="name">Име</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && <p id="name-error" className="error">{errors.name}</p>}
<label htmlFor="email">Имейл</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <p id="email-error" className="error">{errors.email}</p>}
<label htmlFor="address">Адрес</label>
<input
id="address"
value={address}
onChange={(e) => setAddress(e.target.value)}
aria-invalid={!!errors.address}
aria-describedby={errors.address ? 'address-error' : undefined}
/>
{errors.address && <p id="address-error" className="error">{errors.address}</p>}
<label htmlFor="payment">Плащане</label>
<select id="payment" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="cash">Наложен платеж</option>
<option value="card">С карта при доставка</option>
</select>
<label>
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
Приемам условията
</label>
{sending && <p role="status">Изпраща се…</p>}
<button type="submit" disabled={!agree || sending}>
Поръчай
</button>
</form>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Добави const [sending, setSending] = useState(false). Около fetch: setSending(true) преди и setSending(false) след отговора. Бутонът е disabled={!agree || sending}, а {sending && <p role="status">Изпраща се…</p>} стои над него. При грешка вместо общия абзац setErrors(data.errors): съобщенията на сървъра влизат в същото errors и се показват до полетата. Абзацът problem вече не трябва.
Проверката хваща бутон, който остава включен, докато пише „Изпраща се…“, и бутон, който след отговора не се включва повече (забравено false).
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Първият компонент · Условия: празно, грешка, зарежда · Състояние · Данни от API в компонент
- Продължава в: Общо състояние · Витрината на магазина
- Задачи по темата: Ефекти, данни и формуляри