Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

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).

Докладніше в документації: useActionState

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 і текстова зміна на кнопці, щоб стан відправки був зрозумілий не лише візуально.

Докладніше в документації: useFormStatus

Для простих форм вистачає нативних засобів 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.

Докладніше в документації: 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 (забагато спроб).

Докладніше в документації: React Hook Form: setError