Пошук уроків, статей та іншого контенту
Спроєктуйте складну форму з групами полів, залежними значеннями, вкладеними даними та узгодженим станом.
Складна форма зазвичай має кілька характеристик:
поля об’єднані в логічні групи;
значення одного поля залежать від іншого;
дані мають вкладену структуру;
користувач може додавати або видаляти повторювані поля;
валідація стосується не лише окремих полів, а й зв’язків між ними.
Наприклад, форма замовлення може містити:
{
customer: {
firstName: "Олена",
lastName: "Коваль",
email: "olena@example.com"
},
delivery: {
country: "ua",
city: "kyiv",
address: {
street: "Хрещатик",
house: "10",
apartment: "25"
}
},
preferences: {
newsletter: true,
contactMethod: "email"
},
contacts: [
{
id: 1,
type: "phone",
value: "+380..."
}
]
}Така структура зручна для відправлення на сервер, але потребує обережної роботи зі станом.
Для пов’язаної форми варто заздалегідь спроєктувати об’єкт стану:
кожна група полів має власну властивість;
вкладені дані зберігаються у вкладених об’єктах;
повторювані поля зберігаються в масивах;
значення, яке можна обчислити з інших значень, не потрібно дублювати у стані.
Наприклад, список міст можна зберігати окремо як довідник, а в стані форми зберігати лише вибрану країну та місто:
const cityOptions = {
ua: [
{ value: "kyiv", label: "Київ" },
{ value: "lviv", label: "Львів" }
],
pl: [
{ value: "warsaw", label: "Варшава" },
{ value: "krakow", label: "Краків" }
]
};Не потрібно зберігати в стані весь список міст для кожної форми. Його можна отримати з вибраної країни:
const cities = cityOptions[state.delivery.country] ?? [];useReducerДля кількох незалежних полів достатньо useState. Але коли стан має вкладені об’єкти, масиви та залежні значення, зручніше використати useReducer.
Редюсер:
централізує всі зміни стану;
описує зміни через зрозумілі дії;
допомагає не мутувати вкладені об’єкти;
спрощує підтримку форми в міру її розширення.
Кожна дія повинна створювати новий об’єкт, а не змінювати наявний:
return {
...state,
customer: {
...state.customer,
firstName: action.value
}
};У формі нижче місто залежить від країни. Коли країна змінюється, потрібно:
оновити країну;
отримати доступні міста;
вибрати перше доступне місто або очистити поле, якщо міст немає.
Це важливо, тому що після зміни країни попереднє місто може стати недійсним.
Не варто дозволяти компоненту зберігати суперечливий стан на кшталт:
{
country: "pl",
city: "kyiv"
}Краще виправляти залежне значення в одному місці — у редюсері.
Нижче наведено самодостатній компонент форми. Він демонструє:
групування полів через fieldset;
вкладені дані клієнта й адреси;
залежні списки країни та міста;
масив додаткових контактів;
валідацію;
узгоджене оновлення стану через useReducer.
import { useReducer, useState } from "react";
const cityOptions = {
ua: [
{ value: "kyiv", label: "Київ" },
{ value: "lviv", label: "Львів" },
{ value: "odesa", label: "Одеса" }
],
pl: [
{ value: "warsaw", label: "Варшава" },
{ value: "krakow", label: "Краків" },
{ value: "gdansk", label: "Гданськ" }
]
};
const initialState = {
customer: {
firstName: "",
lastName: "",
email: ""
},
delivery: {
country: "ua",
city: "kyiv",
address: {
street: "",
house: "",
apartment: ""
}
},
preferences: {
newsletter: false,
contactMethod: "email"
},
contacts: []
};
function formReducer(state, action) {
switch (action.type) {
case "customer/update":
return {
...state,
customer: {
...state.customer,
[action.field]: action.value
}
};
case "delivery/countryChanged": {
const cities = cityOptions[action.value] ?? [];
return {
...state,
delivery: {
...state.delivery,
country: action.value,
city: cities[0]?.value ?? ""
}
};
}
case "delivery/cityChanged":
return {
...state,
delivery: {
...state.delivery,
city: action.value
}
};
case "delivery/addressUpdated":
return {
...state,
delivery: {
...state.delivery,
address: {
...state.delivery.address,
[action.field]: action.value
}
}
};
case "preferences/updated":
return {
...state,
preferences: {
...state.preferences,
[action.field]: action.value
}
};
case "contact/added":
return {
...state,
contacts: [
...state.contacts,
{
id: Date.now(),
type: "phone",
value: ""
}
]
};
case "contact/updated":
return {
...state,
contacts: state.contacts.map((contact) =>
contact.id === action.id
? {
...contact,
[action.field]: action.value
}
: contact
)
};
case "contact/removed":
return {
...state,
contacts: state.contacts.filter(
(contact) => contact.id !== action.id
)
};
case "form/reset":
return initialState;
default:
return state;
}
}
function validateForm(state) {
const errors = {};
if (!state.customer.firstName.trim()) {
errors["customer.firstName"] = "Вкажіть ім’я";
}
if (!state.customer.lastName.trim()) {
errors["customer.lastName"] = "Вкажіть прізвище";
}
if (!state.customer.email.trim()) {
errors["customer.email"] = "Вкажіть електронну пошту";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(state.customer.email)) {
errors["customer.email"] = "Некоректний формат електронної пошти";
}
if (!state.delivery.country) {
errors["delivery.country"] = "Оберіть країну";
}
if (!state.delivery.city) {
errors["delivery.city"] = "Оберіть місто";
}
if (!state.delivery.address.street.trim()) {
errors["delivery.address.street"] = "Вкажіть вулицю";
}
if (!state.delivery.address.house.trim()) {
errors["delivery.address.house"] = "Вкажіть номер будинку";
}
state.contacts.forEach((contact) => {
if (!contact.value.trim()) {
errors[`contacts.${contact.id}`] = "Вкажіть значення контакту";
}
});
return errors;
}
function FieldError({ children }) {
if (!children) {
return null;
}
return (
<small role="alert" style={{ color: "crimson", display: "block" }}>
{children}
</small>
);
}
export default function App() {
const [state, dispatch] = useReducer(formReducer, initialState);
const [submitted, setSubmitted] = useState(false);
const [savedData, setSavedData] = useState(null);
const errors = validateForm(state);
const cities = cityOptions[state.delivery.country] ?? [];
function handleSubmit(event) {
event.preventDefault();
setSubmitted(true);
if (Object.keys(errors).length > 0) {
setSavedData(null);
return;
}
setSavedData(state);
}
function handleReset() {
dispatch({ type: "form/reset" });
setSubmitted(false);
setSavedData(null);
}
return (
<main>
<h1>Дані клієнта</h1>
<form onSubmit={handleSubmit} noValidate>
<fieldset>
<legend>Особисті дані</legend>
<label>
Ім’я
<input
value={state.customer.firstName}
onChange={(event) =>
dispatch({
type: "customer/update",
field: "firstName",
value: event.target.value
})
}
aria-invalid={
submitted && Boolean(errors["customer.firstName"])
}
/>
</label>
<FieldError>
{submitted && errors["customer.firstName"]}
</FieldError>
<label>
Прізвище
<input
value={state.customer.lastName}
onChange={(event) =>
dispatch({
type: "customer/update",
field: "lastName",
value: event.target.value
})
}
aria-invalid={
submitted && Boolean(errors["customer.lastName"])
}
/>
</label>
<FieldError>
{submitted && errors["customer.lastName"]}
</FieldError>
<label>
Електронна пошта
<input
type="email"
value={state.customer.email}
onChange={(event) =>
dispatch({
type: "customer/update",
field: "email",
value: event.target.value
})
}
aria-invalid={submitted && Boolean(errors["customer.email"])}
/>
</label>
<FieldError>
{submitted && errors["customer.email"]}
</FieldError>
</fieldset>
<fieldset>
<legend>Адреса доставки</legend>
<label>
Країна
<select
value={state.delivery.country}
onChange={(event) =>
dispatch({
type: "delivery/countryChanged",
value: event.target.value
})
}
aria-invalid={
submitted && Boolean(errors["delivery.country"])
}
>
<option value="ua">Україна</option>
<option value="pl">Польща</option>
</select>
</label>
<FieldError>
{submitted && errors["delivery.country"]}
</FieldError>
<label>
Місто
<select
value={state.delivery.city}
onChange={(event) =>
dispatch({
type: "delivery/cityChanged",
value: event.target.value
})
}
aria-invalid={submitted && Boolean(errors["delivery.city"])}
>
{cities.map((city) => (
<option key={city.value} value={city.value}>
{city.label}
</option>
))}
</select>
</label>
<FieldError>
{submitted && errors["delivery.city"]}
</FieldError>
<label>
Вулиця
<input
value={state.delivery.address.street}
onChange={(event) =>
dispatch({
type: "delivery/addressUpdated",
field: "street",
value: event.target.value
})
}
aria-invalid={
submitted && Boolean(errors["delivery.address.street"])
}
/>
</label>
<FieldError>
{submitted && errors["delivery.address.street"]}
</FieldError>
<label>
Номер будинку
<input
value={state.delivery.address.house}
onChange={(event) =>
dispatch({
type: "delivery/addressUpdated",
field: "house",
value: event.target.value
})
}
aria-invalid={
submitted && Boolean(errors["delivery.address.house"])
}
/>
</label>
<FieldError>
{submitted && errors["delivery.address.house"]}
</FieldError>
<label>
Квартира
<input
value={state.delivery.address.apartment}
onChange={(event) =>
dispatch({
type: "delivery/addressUpdated",
field: "apartment",
value: event.target.value
})
}
/>
</label>
</fieldset>
<fieldset>
<legend>Налаштування зв’язку</legend>
<label>
Бажаний спосіб зв’язку
<select
value={state.preferences.contactMethod}
onChange={(event) =>
dispatch({
type: "preferences/updated",
field: "contactMethod",
value: event.target.value
})
}
>
<option value="email">Електронна пошта</option>
<option value="phone">Телефон</option>
</select>
</label>
<label>
<input
type="checkbox"
checked={state.preferences.newsletter}
onChange={(event) =>
dispatch({
type: "preferences/updated",
field: "newsletter",
value: event.target.checked
})
}
/>
Отримувати новини електронною поштою
</label>
</fieldset>
<fieldset>
<legend>Додаткові контакти</legend>
{state.contacts.length === 0 && (
<p>Додаткові контакти ще не додані.</p>
)}
{state.contacts.map((contact) => (
<div key={contact.id}>
<label>
Тип
<select
value={contact.type}
onChange={(event) =>
dispatch({
type: "contact/updated",
id: contact.id,
field: "type",
value: event.target.value
})
}
>
<option value="phone">Телефон</option>
<option value="telegram">Telegram</option>
<option value="viber">Viber</option>
</select>
</label>
<label>
Значення
<input
value={contact.value}
onChange={(event) =>
dispatch({
type: "contact/updated",
id: contact.id,
field: "value",
value: event.target.value
})
}
aria-invalid={
submitted && Boolean(errors[`contacts.${contact.id}`])
}
/>
</label>
<FieldError>
{submitted && errors[`contacts.${contact.id}`]}
</FieldError>
<button
type="button"
onClick={() =>
dispatch({
type: "contact/removed",
id: contact.id
})
}
>
Видалити контакт
</button>
</div>
))}
<button
type="button"
onClick={() => dispatch({ type: "contact/added" })}
>
Додати контакт
</button>
</fieldset>
<div>
<button type="submit">Зберегти</button>
<button type="button" onClick={handleReset}>
Очистити
</button>
</div>
</form>
{submitted && Object.keys(errors).length > 0 && (
<p role="alert">Перевірте поля з помилками.</p>
)}
{savedData && (
<section>
<h2>Дані для відправлення</h2>
<pre>{JSON.stringify(savedData, null, 2)}</pre>
</section>
)}
</main>
);
}Для поля street шлях до значення має вигляд:
state.delivery.address.streetПід час оновлення потрібно зберегти всі рівні вкладеності:
case "delivery/addressUpdated":
return {
...state,
delivery: {
...state.delivery,
address: {
...state.delivery.address,
[action.field]: action.value
}
}
};Якщо пропустити один із операторів розгортання, можна випадково видалити сусідні дані:
// Неправильно: властивості delivery.address буде втрачено
return {
...state,
delivery: {
address: {
[action.field]: action.value
}
}
};Для масиву контактів не можна змінювати елемент напряму:
// Неправильно
contact.value = newValue;Потрібно створити новий масив через map:
contacts: state.contacts.map((contact) =>
contact.id === action.id
? { ...contact, value: action.value }
: contact
)Для видалення використовується filter:
contacts: state.contacts.filter(
(contact) => contact.id !== action.id
)Кожен повторюваний елемент повинен мати стабільний key. Не варто використовувати індекс масиву, оскільки після видалення елементів React може неправильно зіставити поля з їхнім станом:
{state.contacts.map((contact) => (
<div key={contact.id}>
...
</div>
))}Усі поля прикладу є контрольованими:
<input
value={state.customer.firstName}
onChange={handleChange}
/>Це означає:
значення поля надходить зі стану React;
кожна зміна передається в dispatch;
стан є єдиним джерелом правди;
значення форми можна перевірити або відправити в будь-який момент.
Для прапорця checkbox потрібно використовувати властивість checked, а не value:
<input
type="checkbox"
checked={state.preferences.newsletter}
onChange={(event) =>
dispatch({
type: "preferences/updated",
field: "newsletter",
value: event.target.checked
})
}
/>Для текстових полів і списків використовується event.target.value.
Функція валідації отримує весь стан, тому може перевіряти:
окремі поля;
вкладені поля;
залежні значення;
кожен елемент масиву.
У прикладі помилки зберігаються за ключами-шляхами:
{
"customer.email": "Некоректний формат електронної пошти",
"delivery.address.street": "Вкажіть вулицю"
}Таку структуру легко використовувати під час відображення помилки біля відповідного поля.
Валідацію краще відокремлювати від JSX. Компонент відповідає за відображення, а validateForm — за правила перевірки. Це робить правила зрозумілішими та спрощує їх тестування.
// Неправильно
state.delivery.address.street = value;Мутація може призвести до того, що React не виявить зміну правильно. Завжди створюйте нові об’єкти та масиви.
Не потрібно зберігати окремо cities у стані, якщо його можна отримати з country. Дубльовані дані можуть розійтися.
Краще:
const cities = cityOptions[state.delivery.country] ?? [];Після зміни країни потрібно перевірити місто. Якщо цього не зробити, форма може містити недійсну пару значень.
value для checkboxДля checkbox правильна властивість — checked:
<input type="checkbox" checked={value} />key у повторюваних поляхКожен елемент масиву повинен мати стабільний ідентифікатор. Використання індексу як key небезпечне для форм із додаванням і видаленням елементів.
Поле не повинно спочатку отримувати undefined, а потім рядок. Ініціалізуйте всі значення:
{
firstName: "",
email: "",
newsletter: false
}Для складної форми в React:
спочатку спроєктуйте вкладену структуру стану;
об’єднуйте пов’язані поля в групи;
використовуйте useReducer, коли змін стану стає багато;
оновлюйте вкладені об’єкти та масиви без мутацій;
зберігайте лише основні значення, а похідні обчислюйте;
підтримуйте узгодженість залежних полів у редюсері;
використовуйте стабільні ідентифікатори для повторюваних полів;
відокремлюйте валідацію від JSX;
робіть усі поля контрольованими.