Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Форми та обробка форм у React». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Обробка форм у React** будується на двох підходах: контрольовані та неконтрольовані компоненти (controlled / uncontrolled). Контрольовані: React state зберігає значення, `onChange` оновлює його при кожному натисканні. Неконтрольовані: значення зберігає DOM, `useRef` читає його при submit. ```tsx // Контрольований const [email, setEmail] = useState(""); <input value={email} onChange={(e) => setEmail(e.target.value)} /> // Неконтрольований const ref = useRef<HTMLInputElement>(null); <input ref={ref} /> // ref.current?.value при submit ``` **Головне правило:** контрольовані для живої валідації та умовних полів, неконтрольовані для файлів і великих форм.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Обробка форм у 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 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()`** ```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`](/questions/usestate-hook), або перехід на неконтрольований підхід і читання даних тільки при 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 буде зручнішим.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.