Форми та обробка форм у React
Обробка форм у React зводиться до одного рішення: хто зберігає значення поля вводу, React чи DOM? Від цього залежить, коли дані стають доступними, як часто компоненти перерендерюються і скільки коду треба писати.
Теорія
TL;DR
- Контрольовані компоненти (controlled components): React state зберігає значення кожного поля,
onChangeсинхронізує при кожному натисканні - Неконтрольовані компоненти (uncontrolled components): значення зберігає DOM,
useRefчитає його тільки при submit - Аналогія: контрольований - маріонетка на нитках (React тягне кожен рух); неконтрольований - замкнений сейф (React відкриває лише коли потрібно)
- Жива валідація або умовні поля → контрольовані; файлові поля, великі форми, сторонні бібліотеки → неконтрольовані
- React 19 додає server actions:
useActionStateбере на себе submit і серверні мутації без окремого API-маршруту
Швидкий приклад
// Контрольований: React зберігає значення
const [value, setValue] = useState("");
<input value={value} onChange={(e) => setValue(e.target.value)} />
// Перерендер при кожному натисканні
// Неконтрольований: DOM зберігає значення, ref читає при submit
const ref = useRef<HTMLInputElement>(null);
<input ref={ref} />
const submitted = ref.current?.value; // читаємо тільки коли потрібноКонтрольований варіант утворює замкнутий цикл між value і onChange. Неконтрольований цей цикл обходить повністю.
Ключова різниця
У контрольованому полі проп value прив'язує відображуване значення до React state. Користувач натискає клавішу, спрацьовує onChange, викликається setState, React ставить в чергу оновлення фібера через scheduleUpdateOnFiber, і компонент перерендерюється з новим значенням. У неконтрольованому полі DOM сам відображає текст, який вводить користувач. useRef вказує на реальний DOM-вузол, і ref.current.value читається тоді, коли потрібні дані, зазвичай на submit.
Коли використовувати
- Поле email, яке показує «невірний формат» поки користувач ще друкує → контрольоване
- Поля, що з'являються залежно від значення іншого поля → контрольовані
- Форми зі 100+ полями, де перерендери при кожному натисканні помітно гальмують → неконтрольовані
<input type="file">→ завжди неконтрольоване (браузер блокує встановленняvalueдля файлових полів з міркувань безпеки)- Сторонні компоненти вводу на зразок React-Select, які керують власним станом → неконтрольовані
- Full-stack Next.js з прямими записами в БД → server actions React 19
Таблиця порівняння
| Аспект | Контрольовані | Неконтрольовані | React 19 Actions |
|---|---|---|---|
| Де живе стан | React state | DOM-вузол через ref | Server action функція |
| Перерендер при зміні | Так, при кожному натисканні | Ні | Ні (на сервері) |
| Момент валідації | Реальний час через onChange | При submit або blur | Сервер + клієнт через useActionState |
| Код на поле | State + обробник | Один ref на форму | Одна action функція |
| Підходить для | Динамічний UI, live-помилки | Файли, великі форми | Next.js RSC + мутації в БД |
Як React обробляє форми зсередини
Коли користувач вводить текст у контрольованому полі, браузер генерує подію input, синтетичний onChange React її перехоплює, setState ставить оновлення фібера в чергу, і компонент перерендерюється з новим значенням value проп. V8 пропускає перерендер, якщо пропси не змінились.
Для неконтрольованих полів useRef чіпляється до DOM-вузла під час фази коміту (ReactFiberCommitWork). Жодного планувальника. Читання ref.current.value звертається напряму до DOM. Тому неконтрольовані форми мають майже нульові витрати при кожному натисканні.
React 19 додає третю модель. Форма з action функцією серіалізує поля у FormData при submit і відправляє POST до server action. useActionState повертає [state, action, isPending], тобто стани pending і error вже вбудовані, без useState для них.
Типові помилки
Забули e.preventDefault()
// Неправильно: нативний submit спрацьовує, сторінка оновлюється, стан React зникає
const handleSubmit = (e) => { login(data); };
// Правильно
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
login(data);
};Мутація стану безпосередньо в onChange
// Неправильно: та сама посилання, React не бачить змін, перерендер не відбувається
const [data, setData] = useState({ name: "" });
// onChange={(e) => { data.name = e.target.value; setData(data); }}
// Правильно: нова посилання запускає перерендер
onChange={(e) => setData({ ...data, name: e.target.value })}React порівнює посилання на об'єкти, а не їх вміст. Мутація залишає ту саму посилання, тому оновлення невидиме.
Контрольований файловий input
// Неправильно: браузер ігнорує value для файлових полів
<input type="file" value={selectedFile} onChange={...} />
// Правильно: файлові поля завжди неконтрольовані
const fileRef = useRef<HTMLInputElement>(null);
<input type="file" ref={fileRef} />Читання ref.current до монтування
// Неправильно: ref дорівнює null у фазі рендеру
const ref = useRef(null);
const value = ref.current?.value; // тут завжди null
// Правильно: читати в обробнику submit або callback подіїReact 19 action без FormData
Server action отримує FormData, а не JSON. Передача JSON.stringify ламає завантаження файлів, бо FileList не серіалізується в JSON.
// Неправильно
submitAction(JSON.stringify(formData));
// Правильно
const data = new FormData(e.currentTarget as HTMLFormElement);
submitAction(data);Де зустрічається
- React Hook Form: неконтрольований за замовчуванням (використовує refs всередині), підтримує Zod і Yup, де-факто стандарт у продакшені
- Formik: контрольований через
<Field>, використовується в продуктах Shopify і Atlassian - Next.js app router:
"use server"функції обробляють submit форми і записи в БД без окремого API-маршруту - React Final Form: неконтрольовані спостережувані (observables), зустрічається в проектах Salesforce-екосистеми
- Zod + tRPC: валідація схеми в
onChangeдля типобезпечних full-stack застосунків
З досвіду реальних проектів: команди, які починають зі звичайних контрольованих компонентів для складних форм, майже завжди переходять на React Hook Form за місяць. Простіше почати з нього одразу.
Питання на співбесіді
Q: Яка вартість продуктивності контрольованої форми з 200 полями?
A: Кожне натискання запускає оновлення стану і прохід reconciler по всьому піддереву. Варіанти: useDeferredValue для деприоритизації оновлень, розбиття стану ближче до кожного поля через useState, або перехід на неконтрольований підхід і читання даних тільки при submit.
Q: Чим useActionState відрізняється від ручного патерну useState + fetch?
A: useActionState інтегрується зі scheduler React і <form action={...}> напряму. isPending приходить вбудований без окремого стану, server action виконується на сервері без API-маршруту, помилки повертаються як значення в кортежі стану.
Q: Чому не можна встановити value для <input type="file">?
A: Безпека браузера. Скрипт, що міг би підставляти значення у файловий input, міг би непомітно вибирати файли і відправляти їх на сервер. Специфікація повністю блокує присвоєння value для файлових полів.
Q: Як обробляти async-валідацію, не блокуючи введення?
A: Позначити оновлення валідації через useTransition як неприоритетне. Поле залишається чуйним, поки async-перевірка виконується у фоні. Додати useDebounce, щоб не відправляти мережевий запит при кожному натисканні.
Q: Спроектуй форму з оптимістичними оновленнями, server actions і rollback при помилці. Яка race condition може виникнути?
A: useOptimistic для миттєвого оновлення UI, useActionState для server action. Послідовність: submit запускає useOptimistic (результат відображається одразу) → server action виконується → при успіху React reconciles; при помилці throw в action запускає rollback. Race condition: користувач відправляє форму двічі до завершення першої action. Друге оптимістичне оновлення перезаписує перше, потім приходить відповідь першого сервера і стан UI стає неконсистентним. Рішення: блокувати submit через isPending з useFormStatus поки action виконується.
Приклади
Контрольована форма логіну з валідацією
import { useState } from "react";
function LoginForm() {
const [formData, setFormData] = useState({ email: "", password: "" });
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (data: typeof formData) => {
const next: Record<string, string> = {};
if (!data.email.includes("@")) next.email = "Невірний email";
if (data.password.length < 8) next.password = "Мінімум 8 символів";
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (validate(formData)) {
await fetch("/api/login", {
method: "POST",
body: JSON.stringify(formData),
});
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="Email"
/>
{errors.email && <span>{errors.email}</span>}
<input
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
/>
{errors.password && <span>{errors.password}</span>}
<button type="submit">Увійти</button>
</form>
);
}Обидва поля використовують один об'єкт стану. Spread у onChange створює нову посилання, щоб React побачив зміну. validate виконується при submit і блокує fetch, якщо щось не так.
Неконтрольований файловий upload з useActionState
import { useRef, useActionState } from "react";
async function uploadAction(_: unknown, formData: FormData) {
const files = formData.getAll("files") as File[];
// await uploadToS3(files);
return { uploaded: files.length }; // { uploaded: 2 }
}
function FileUpload() {
const fileRef = useRef<HTMLInputElement>(null);
const [state, submitAction, isPending] = useActionState(uploadAction, null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// FormData підбирає файловий input за атрибутом name
const data = new FormData(e.currentTarget as HTMLFormElement);
submitAction(data);
};
return (
<form onSubmit={handleSubmit}>
<input ref={fileRef} name="files" type="file" multiple />
<button type="submit" disabled={isPending}>
{isPending ? "Завантаження..." : "Відправити"}
</button>
{state && <p>Завантажено файлів: {state.uploaded}</p>}
</form>
);
}new FormData(form) збирає файловий input за його атрибутом name. useActionState відстежує стан pending без окремого useState. Action повертає результат, React поміщає його в state.
Форма з кількома полями на useReducer
type FormState = {
name: string;
email: string;
role: string;
errors: Record<string, string>;
};
type FormAction =
| { type: "SET_FIELD"; field: string; value: string }
| { type: "SET_ERROR"; field: string; error: string }
| { type: "RESET" };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "SET_FIELD":
return { ...state, [action.field]: action.value };
case "SET_ERROR":
return {
...state,
errors: { ...state.errors, [action.field]: action.error },
};
case "RESET":
return { name: "", email: "", role: "", errors: {} };
}
}
function RegistrationForm() {
const [state, dispatch] = useReducer(formReducer, {
name: "",
email: "",
role: "",
errors: {},
});
return (
<form>
<input
value={state.name}
onChange={(e) =>
dispatch({ type: "SET_FIELD", field: "name", value: e.target.value })
}
/>
{state.errors.name && <span>{state.errors.name}</span>}
</form>
);
}SET_FIELD обробляє будь-яке поле за назвою, тому додати нове поле означає лише новий <input>, без нового useState. RESET очищує все одним dispatch. Цей підхід підходить до 10-15 полів, далі React Hook Form буде зручнішим.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.