Middle: питання на співбесіді з теми «Форми й Actions»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
useActionState (React 19) - стан, що оновлюється результатом дії (Action). Зручно для форм: дія відправляє дані, а повернене значення стає новим станом - повідомленням про успіх чи помилками.
import { useActionState } from 'react';
async function subscribe(previousState, formData) {
const response = await fetch('/api/subscribe', {
method: 'POST',
headers: { Accept: 'application/json' },
body: formData,
});
if (response.status === 422) {
const { errors } = await response.json();
return { errors, email: formData.get('email') };
}
return { success: true };
}
function SubscribeForm() {
const [state, formAction, isPending] = useActionState(subscribe, { errors: {} });
if (state.success) return <p>Дякуємо за підписку!</p>;
return (
<form action={formAction}>
<input name="email" type="email" defaultValue={state.email} />
{state.errors?.email && <p role="alert">{state.errors.email[0]}</p>}
<button disabled={isPending}>{isPending ? 'Надсилаємо...' : 'Підписатися'}</button>
</form>
);
}
Як працює:
useActionState(reducerAction, initialState)повертає[state, dispatchAction, isPending];reducerAction(previousState, payload)- як редюсер уuseReducer, але може бути асинхронним і мати побічні ефекти. Для формиpayload- цеFormData;isPending- чи виконується дія;- кілька викликів ставляться в чергу й виконуються послідовно: кожен отримує результат попереднього. Подвійне натискання не дасть двох паралельних запитів у довільному порядку;
dispatchActionтреба викликати з дії: передати в<form action>чи<button formAction>, або обгорнути вstartTransition.
Пастки:
- форма скидається після успішної дії - неконтрольовані поля очищуються. Щоб не губити введене при помилці, повертайте значення в стані (
defaultValue={state.email}, як у прикладі); - кинутий виняток у дії скасовує всі дії в черзі й показує найближчий Error Boundary. Очікувані помилки (валідація) - повертати як стан, а не кидати;
- початковий стан і тип результату мають збігатися - інакше TypeScript скаржиться на невідповідність типів.
Server Functions: з Next.js чи іншим RSC-фреймворком дія може бути серверною функцією ('use server') - тоді форма працює навіть до завантаження JavaScript (третій аргумент permalink).
useFormStatus (з react-dom) повертає стан відправки батьківської форми: pending, data (FormData, що відправляється), method, action.
import { useFormStatus } from 'react-dom';
function SubmitButton({ children }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-busy={pending}>
{pending ? 'Зберігаємо...' : children}
</button>
);
}
function ProfileForm() {
return (
<form action={updateProfile}>
<input name="name" />
<SubmitButton>Зберегти</SubmitButton>
</form>
);
}
Кнопку з власним станом можна використовувати в будь-якій формі - без передачі isSubmitting через props.
Чому pending завжди false. Хук бачить лише форму, всередині якої рендериться компонент. Якщо викликати його в тому самому компоненті, що рендерить <form>, батьківської форми для хука немає:
function ProfileForm() {
const { pending } = useFormStatus(); // завжди false: форма нижче, а не вище
return <form action={updateProfile}>...</form>;
}
Тому стан відправки виносять в окремий дочірній компонент (кнопка, індикатор), а в компоненті з формою для цього є isPending з useActionState.
Інші умови роботи:
- форма має відправлятися через дію (
action={функція}). ЗonSubmitчиaction="/url"хук нічого не знає про відправку; - поле
dataдає змогу показати, що саме відправляється («Додаємо "Купити молоко"...»).
Навіщо блокувати кнопку на час відправки: від подвійних відправок (два замовлення, два коментарі). Але це UX-захист - сервер теж має бути готовим до повторів (ключі ідемпотентності, унікальні обмеження).
Доступність: aria-busy і текстова зміна на кнопці, щоб стан відправки був зрозумілий не лише візуально.
Для простих форм вистачає нативних засобів React 19 (action, useActionState). Коли полів багато, є валідація з залежностями між полями, динамічні списки полів і складний UX помилок, - зручніше бібліотека.
React Hook Form керує формою через неконтрольовані поля й refs: введення не викликає рендер усієї форми. Стан помилок, «брудності», відправки - окремо.
Zod описує схему даних, з якої виходить і валідація, і тип TypeScript.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.email('Некоректна адреса'),
password: z.string().min(8, 'Щонайменше 8 символів'),
age: z.coerce.number().int().min(18, 'Лише для повнолітніх'),
});
type FormValues = z.infer<typeof schema>;
function SignUpForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) });
const onSubmit = async (values: FormValues) => {
await api.signUp(values);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input type="email" {...register('email')} aria-invalid={!!errors.email} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input type="password" {...register('password')} />
{errors.password && <p role="alert">{errors.password.message}</p>}
<input type="number" {...register('age')} />
<button disabled={isSubmitting}>Зареєструватися</button>
</form>
);
}
Що дає поєднання:
- одна схема - і правила, і тип даних (
z.infer); схему можна використати повторно на сервері (Node) чи для перевірки відповіді API; handleSubmitвикликаєonSubmitлише з валідними даними, а при помилках ставить фокус на перше невалідне поле;- режими перевірки:
mode: 'onSubmit'(за замовчуванням),'onBlur','onChange','onTouched'.
Що варто знати:
- у Zod 4 формати рядків стали окремими функціями:
z.email()замість старогоz.string().email()(той лишився, але застарів); - значення полів - рядки: для чисел -
z.coerce.number()чиvalueAsNumberуregister; - клієнтська валідація - лише зручність. Сервер (Laravel Form Request) перевіряє все заново, а його помилки треба показати у формі через
setError; - для сторонніх компонентів (селекти, редактори) -
Controllerз React Hook Form.
Коли API на Laravel не пропускає дані, він відповідає 422 Unprocessable Content з JSON:
{
"message": "The email has already been taken. (and 1 more error)",
"errors": {
"email": ["Ця адреса вже зайнята."],
"items.0.qty": ["Кількість має бути не менше 1."]
}
}
Laravel повертає JSON (а не редирект назад), якщо запит має заголовок Accept: application/json - його обов'язково треба надсилати з fetch.
З React Hook Form - setError:
const { register, handleSubmit, setError, formState: { errors } } = useForm();
async function onSubmit(values) {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(values),
});
if (response.status === 422) {
const { errors: serverErrors } = await response.json();
for (const [field, messages] of Object.entries(serverErrors)) {
setError(field, { type: 'server', message: messages[0] }, { shouldFocus: true });
}
return;
}
if (!response.ok) {
setError('root.server', { message: 'Не вдалося зберегти. Спробуйте ще раз.' });
return;
}
}
{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
Що тут важливо:
- ключі Laravel з крапками (
items.0.qty) збігаються з іменами полів React Hook Form для масивів (register('items.0.qty')) - помилка потрапить точно до потрібного поля; - кілька повідомлень на поле - Laravel повертає масив; зазвичай показують перше;
- помилки, що не стосуються поля (сервер недоступний, 403, 500) - окремо, через
root.*; - серверні помилки скидаються при наступній зміні поля чи відправці - так поводиться React Hook Form для
setError.
Без бібліотеки - те саме зі станом: const [errors, setErrors] = useState({}) і useActionState, що повертає errors з 422-відповіді.
Inertia поводиться інакше: там Laravel відповідає не 422, а редиректом назад з помилками в сесії, і вони приходять у сторінку як проп errors (useForm().errors) - ручна обробка не потрібна.
Інші статуси, які варто обробити: 419 (сесія чи CSRF-токен застаріли - запропонувати оновити сторінку) і 429 (забагато спроб).