Пошук уроків, статей та іншого контенту
Об’єднайте асинхронну перевірку, обробку серверних помилок і стани завантаження в надійній формі.
Асинхронна форма зазвичай взаємодіє з сервером у двох місцях:
Під час заповнення поля — наприклад, перевіряє доступність імені користувача.
Під час надсилання — сервер повторно перевіряє всі дані та може повернути помилки.
Перевірка в браузері не замінює серверну перевірку. Користувач може обійти клієнтський код, а стан даних на сервері може змінитися між перевіркою поля та надсиланням форми.
Форма має розрізняти щонайменше такі стани:
звичайний стан;
перевірка окремого поля;
надсилання форми;
помилка конкретного поля від сервера;
загальна серверна помилка;
успішне надсилання.
Розглянемо поле username. Після втрати фокусу форма надсилає запит на сервер, щоб перевірити, чи вільне ім’я.
Важливо врахувати ситуацію, коли користувач швидко змінює значення:
Для alex надсилається перший запит.
Користувач одразу вводить alex_dev.
Надсилається другий запит.
Відповідь першого запиту приходить пізніше за відповідь другого.
Якщо без додаткового захисту записати обидві відповіді в стан, застаріла відповідь може перезаписати актуальний результат.
Для захисту використовують:
AbortController, щоб скасувати попередній запит;
ідентифікатор запиту, щоб ігнорувати застарілі відповіді.
Зручно домовитися, що сервер повертає помилки в такому форматі:
{
"message": "Перевірте введені дані",
"fieldErrors": {
"username": "Це ім’я вже зайняте",
"email": "Користувач із таким email уже існує"
}
}Помилки полів зберігаються окремо:
{
username: "Це ім’я вже зайняте",
email: "Користувач із таким email уже існує"
}А помилка, яка не стосується одного поля, зберігається в окремому стані formError.
У прикладі нижче серверні функції імітуються через setTimeout. У реальному застосунку їх можна замінити на fetch, зберігши такий самий контракт результатів.
import { useRef, useState } from "react";
function wait(ms, signal) {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
if (signal.aborted) {
clearTimeout(timer);
reject(new DOMException("Запит скасовано", "AbortError"));
return;
}
signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(new DOMException("Запит скасовано", "AbortError"));
},
{ once: true }
);
});
}
// Імітація GET-запиту для перевірки імені користувача
async function checkUsernameOnServer(username, signal) {
await wait(700, signal);
return {
available: !["admin", "root", "alex"].includes(username.toLowerCase()),
};
}
// Імітація POST-запиту для надсилання форми
async function submitRegistration(data, signal) {
await wait(1000, signal);
if (data.email === "existing@example.com") {
return {
ok: false,
status: 422,
body: {
message: "Перевірте дані форми",
fieldErrors: {
email: "Користувач із таким email уже існує",
},
},
};
}
if (data.username === "server_error") {
return {
ok: false,
status: 500,
body: {
message: "Сервіс тимчасово недоступний",
},
};
}
return {
ok: true,
status: 201,
body: {
id: 42,
username: data.username,
},
};
}
function validateForm(values) {
const errors = {};
if (values.username.trim().length < 3) {
errors.username = "Ім’я має містити щонайменше 3 символи";
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
errors.email = "Введіть коректний email";
}
if (values.password.length < 8) {
errors.password = "Пароль має містити щонайменше 8 символів";
}
return errors;
}
export default function RegistrationForm() {
const [values, setValues] = useState({
username: "",
email: "",
password: "",
});
const [errors, setErrors] = useState({});
const [formError, setFormError] = useState("");
const [checkingUsername, setCheckingUsername] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [successMessage, setSuccessMessage] = useState("");
const usernameRequestId = useRef(0);
const usernameController = useRef(null);
async function checkUsername(username) {
usernameController.current?.abort();
const controller = new AbortController();
usernameController.current = controller;
const requestId = ++usernameRequestId.current;
setCheckingUsername(true);
try {
const result = await checkUsernameOnServer(
username.trim(),
controller.signal
);
// Ігноруємо відповідь неактуального запиту
if (requestId !== usernameRequestId.current) {
return "cancelled";
}
return result.available ? "available" : "taken";
} catch (error) {
if (error.name === "AbortError") {
return "cancelled";
}
throw error;
} finally {
if (requestId === usernameRequestId.current) {
setCheckingUsername(false);
}
}
}
async function handleUsernameBlur() {
const usernameError = validateForm(values).username;
if (usernameError) {
setErrors((current) => ({
...current,
username: usernameError,
}));
return;
}
try {
const result = await checkUsername(values.username);
if (result === "taken") {
setErrors((current) => ({
...current,
username: "Це ім’я вже зайняте",
}));
} else if (result === "available") {
setErrors((current) => {
const next = { ...current };
delete next.username;
return next;
});
}
} catch {
setFormError("Не вдалося перевірити ім’я користувача");
}
}
function handleChange(event) {
const { name, value } = event.target;
setValues((current) => ({
...current,
[name]: value,
}));
// Стара помилка поля більше не обов’язково актуальна
setErrors((current) => {
const next = { ...current };
delete next[name];
return next;
});
setFormError("");
setSuccessMessage("");
}
async function handleSubmit(event) {
event.preventDefault();
setFormError("");
setSuccessMessage("");
const syncErrors = validateForm(values);
setErrors(syncErrors);
if (Object.keys(syncErrors).length > 0) {
return;
}
setSubmitting(true);
const controller = new AbortController();
try {
// Перед надсиланням повторюємо асинхронну перевірку.
// Перевірка під час blur не повинна бути єдиною гарантією.
const usernameResult = await checkUsername(values.username);
if (usernameResult === "taken") {
setErrors({
username: "Це ім’я вже зайняте",
});
return;
}
if (usernameResult !== "available") {
return;
}
const response = await submitRegistration(values, controller.signal);
if (response.ok) {
setValues({
username: "",
email: "",
password: "",
});
setErrors({});
setSuccessMessage("Реєстрацію успішно завершено");
return;
}
if (response.status === 422) {
const serverErrors = response.body.fieldErrors ?? {};
setErrors(serverErrors);
setFormError(response.body.message ?? "Перевірте дані форми");
return;
}
setFormError(
response.body.message ?? "Сталася невідома серверна помилка"
);
} catch (error) {
if (error.name !== "AbortError") {
setFormError("Не вдалося виконати запит. Спробуйте ще раз");
}
} finally {
setSubmitting(false);
}
}
const isBusy = checkingUsername || submitting;
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="username">Ім’я користувача</label>
<input
id="username"
name="username"
value={values.username}
onChange={handleChange}
onBlur={handleUsernameBlur}
disabled={submitting}
aria-invalid={Boolean(errors.username)}
aria-describedby="username-error"
/>
{checkingUsername && <p>Перевірка імені...</p>}
{errors.username && (
<p id="username-error" role="alert">
{errors.username}
</p>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={values.email}
onChange={handleChange}
disabled={submitting}
aria-invalid={Boolean(errors.email)}
aria-describedby="email-error"
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email}
</p>
)}
</div>
<div>
<label htmlFor="password">Пароль</label>
<input
id="password"
name="password"
type="password"
value={values.password}
onChange={handleChange}
disabled={submitting}
aria-invalid={Boolean(errors.password)}
aria-describedby="password-error"
/>
{errors.password && (
<p id="password-error" role="alert">
{errors.password}
</p>
)}
</div>
{formError && <p role="alert">{formError}</p>}
{successMessage && <p role="status">{successMessage}</p>}
<button type="submit" disabled={isBusy}>
{submitting ? "Надсилання..." : "Зареєструватися"}
</button>
</form>
);
}Перевірка username під час blur потрібна для зручності користувача, але не гарантує коректності даних під час надсилання.
Між двома запитами ім’я може зайняти інший користувач. Тому перед POST виконується ще одна перевірка.
Сервер під час самого POST також повинен повторно перевірити унікальність імені.
checkingUsername і submitting — різні стани:
checkingUsername показує індикатор біля конкретного поля;
submitting блокує форму та змінює текст кнопки.
Не варто використовувати один прапорець loading для всіх асинхронних операцій. Інакше перевірка одного поля може заблокувати всю форму або, навпаки, надсилання форми не буде правильно відображене.
422Статус 422 Unprocessable Entity зазвичай означає, що формат запиту правильний, але значення не пройшли бізнес-валідацію.
Такі помилки потрібно показувати біля відповідних полів:
setErrors({
username: "Це ім’я вже зайняте",
email: "Такий email уже використовується",
});Не слід показувати всі помилки лише одним повідомленням над формою. Загальне повідомлення можна залишити додатково, але користувач має бачити, яке поле потрібно виправити.
Не кожна помилка належить конкретному полю. Наприклад:
сервер тимчасово недоступний;
перевищено ліміт запитів;
операція заборонена;
сталася внутрішня помилка.
Такі повідомлення зберігаються у formError:
setFormError("Сервіс тимчасово недоступний");Для загального повідомлення використано role="alert", щоб допоміжні технології оголосили про нього користувачу.
fetch у реальному застосункуІмітацію submitRegistration можна замінити функцією на основі fetch:
async function submitRegistration(data, signal) {
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
signal,
});
const body = await response.json();
return {
ok: response.ok,
status: response.status,
body,
};
}Важливо: fetch не вважає HTTP-помилки винятками. Відповідь зі статусом 422 або 500 потрапить у звичайний результат, тому потрібно перевіряти response.ok або response.status.
Винятки виникають, наприклад, через:
відсутність мережі;
скасування запиту;
проблеми з виконанням самого запиту.
Проблемний код:
const result = await checkUsernameOnServer(username);
setErrors({ username: result.message });Якщо запитів кілька, старіша відповідь може прийти останньою. Використовуйте AbortController та ідентифікатор поточного запиту.
Клієнтська перевірка покращує взаємодію з формою, але не є захистом даних. Сервер повинен перевіряти всі правила самостійно.
Без submitting користувач може натиснути кнопку кілька разів і створити дублікати запитів.
Кнопку потрібно блокувати:
<button type="submit" disabled={submitting}>
{submitting ? "Надсилання..." : "Зберегти"}
</button>Після зміни email не потрібно видаляти помилку username. Очищайте лише помилку поля, яке змінилося.
Серверна помилка 500 не означає, що користувач неправильно заповнив певне поле. Відображайте її як загальну помилку форми та не змінюйте значення полів.
Скасування через AbortController — очікувана ситуація, а не помилка, яку потрібно показувати користувачу. Її слід відрізняти від мережевої або серверної помилки.
Асинхронна перевірка поля має власний стан завантаження.
Відповіді застарілих запитів потрібно ігнорувати.
AbortController допомагає скасувати непотрібні запити.
Перед надсиланням форму потрібно перевірити повторно.
Серверні помилки полів зберігаються окремо від загальної помилки форми.
HTTP-помилки потрібно обробляти через response.status або response.ok.
Під час надсилання потрібно блокувати повторні дії користувача.
Клієнтська валідація покращує UX, але остаточне рішення завжди залишається за сервером.