Skip to main content

Форми та обробка форм у 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-маршруту

Швидкий приклад

tsx
// Контрольований: 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 stateDOM-вузол через refServer 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()

tsx
// Неправильно: нативний submit спрацьовує, сторінка оновлюється, стан React зникає const handleSubmit = (e) => { login(data); }; // Правильно const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); login(data); };

Мутація стану безпосередньо в onChange

tsx
// Неправильно: та сама посилання, 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

tsx
// Неправильно: браузер ігнорує value для файлових полів <input type="file" value={selectedFile} onChange={...} /> // Правильно: файлові поля завжди неконтрольовані const fileRef = useRef<HTMLInputElement>(null); <input type="file" ref={fileRef} />

Читання ref.current до монтування

tsx
// Неправильно: 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.

tsx
// Неправильно 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 виконується.

Приклади

Контрольована форма логіну з валідацією

tsx
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

tsx
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

tsx
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 буде зручнішим.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?