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

Питання на співбесіді: Форми й Actions

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

12 питань

Неконтрольоване поле зберігає значення саме, у DOM. React лише задає початкове значення:

<input name="title" defaultValue="Чернетка" />

Значення читають у момент відправки - з FormData чи через ref.

Контрольоване поле - значення живе в стані React, а поле лише показує його:

const [title, setTitle] = useState('');

<input value={title} onChange={(e) => setTitle(e.target.value)} />

Кожне натискання клавіші - onChange → новий стан → новий рендер з новим value.

Коли контрольоване:

  • значення потрібне під час введення: лічильник символів, пошук «на льоту», валідація в реальному часі;
  • поля залежать одне від одного (вибір країни змінює список міст);
  • значення треба форматувати чи обмежувати (лише цифри, верхній регістр);
  • кнопка «Зберегти» активна лише при зміненій формі.

Коли неконтрольоване:

  • значення потрібне лише при відправці - звичайна форма з <form action> чи onSubmit і FormData;
  • великі форми, де рендер на кожну літеру помітно гальмує;
  • <input type="file"> - завжди неконтрольоване: значення файлового поля задати з коду не можна.

Правила, які не можна порушувати:

  • поле не може бути одночасно контрольованим і неконтрольованим, і не повинне перемикатися між режимами. Типова причина - value={user.name}, де name спершу undefined: поле стартує неконтрольованим, а потім стає контрольованим. Початкове значення - порожній рядок '', а не null чи undefined;
  • для чекбоксів - checked/defaultChecked, а не value.

Бібліотеки форм (React Hook Form) за замовчуванням працюють з неконтрольованими полями саме заради продуктивності, а стан помилок і «брудності» ведуть окремо.

Докладніше в документації: input: керування полем через стан

Класичний спосіб - onSubmit:

function SearchForm() {
  function handleSubmit(e) {
    e.preventDefault();                       // інакше браузер перезавантажить сторінку
    const data = new FormData(e.currentTarget);
    search(data.get('q'));
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="q" />
      <button type="submit">Шукати</button>
    </form>
  );
}

React 19 - функція в пропі action:

function SearchForm() {
  async function searchAction(formData) {
    await search(formData.get('q'));
  }

  return (
    <form action={searchAction}>
      <input name="q" />
      <button type="submit">Шукати</button>
    </form>
  );
}

Чим action відрізняється:

  • preventDefault() не потрібен - React сам перехоплює відправку;
  • функція отримує FormData одразу аргументом;
  • виконується всередині Transition - на ній працюють useFormStatus (стан відправки для кнопки), useActionState (результат дії) і useOptimistic;
  • після успішного виконання неконтрольовані поля форми скидаються автоматично. Якщо поле має лишитися заповненим (пошук), значення треба зберігати в стані чи повертати з дії;
  • метод HTTP завжди POST, незалежно від атрибута method;
  • різні кнопки можуть викликати різні дії через formAction:
<button type="submit">Опублікувати</button>
<button formAction={saveDraft}>Зберегти чернетку</button>

Коли все ж onSubmit: складна клієнтська валідація перед відправкою, бібліотеки форм зі своїм handleSubmit (React Hook Form), потреба повністю контролювати процес.

Пастка для обох способів: кнопка без type усередині форми - це type="submit". Кнопка «Скасувати» чи «Додати рядок» без type="button" відправить форму.

Докладніше в документації: form: обробка відправки

Поле не реагує на введення. Причина - value без onChange:

<input value={title} />   // поле лише для читання

Контрольоване поле завжди показує те, що передано у value. Користувач натискає клавішу, браузер змінює поле, React повертає старе значення. У консолі - попередження: «You provided a value prop to a form field without an onChange handler».

Варіанти виправлення:

  • потрібне лише початкове значення - defaultValue={title};
  • поле має керуватися станом - додати onChange={(e) => setTitle(e.target.value)};
  • поле справді лише для читання - явно readOnly.

Курсор стрибає в кінець чи на початок. Типові причини:

1. Асинхронне оновлення стану:

onChange={async (e) => {
  await validate(e.target.value);
  setTitle(e.target.value);   // запізно - поле вже перемальовано зі старим значенням
}}

Стан контрольованого поля треба оновлювати синхронно в обробнику, а асинхронну перевірку робити окремо.

2. Поле перестворюється на кожен рендер:

  • компонент поля оголошено всередині іншого компонента - на кожен рендер це новий тип компонента, React знищує старий <input> і створює новий;
  • змінюється key поля (наприклад, key={Math.random()}).
function Form() {
  // помилка: новий компонент на кожен рендер
  const Field = (props) => <input {...props} />;
  return <Field value={title} onChange={...} />;
}

Компоненти оголошують на верхньому рівні модуля.

3. Перетворення значення на ходу (setTitle(e.target.value.toUpperCase())) іноді збиває позицію курсора в деяких браузерах. Для форматування (телефон, сума) краще форматувати при виході з поля чи спеціальні бібліотеки масок.

value={null} чи undefined робить поле неконтрольованим - а коли значення з'явиться, React попередить про перемикання режиму. Початкове значення - ''.

Докладніше в документації: input: розв'язання проблем

Доступна форма - та, якою можна користуватися з клавіатури й зчитувачем екрана. Основа - правильні зв'язки між полями, мітками й повідомленнями.

Мітка для кожного поля. placeholder мітку не замінює: він зникає при введенні й погано читається.

<label htmlFor="email">Електронна адреса</label>
<input id="email" name="email" type="email" />

У JSX - htmlFor, а не for.

Унікальні id - через useId. Жорсткий id="email" ламається, якщо компонент поля використано на сторінці двічі. useId генерує стабільний унікальний id, однаковий на сервері й клієнті (без розбіжностей гідратації):

function TextField({ label, error, ...props }) {
  const id = useId();
  const errorId = `${id}-error`;

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? errorId : undefined}
        {...props}
      />
      {error && <p id={errorId} role="alert">{error}</p>}
    </div>
  );
}
  • aria-invalid - зчитувач оголосить, що поле з помилкою;
  • aria-describedby - текст помилки прочитається разом з полем;
  • role="alert" - повідомлення оголошується одразу при появі.

useId - не для ключів у списках: ключі мають походити з даних.

Інші правила:

  • групи радіокнопок і чекбоксів - у <fieldset> з <legend>;
  • фокус на першій помилці після невдалої відправки (React Hook Form робить це сам - shouldFocusError);
  • не вимикати кнопку відправки «доки форма невалідна» - користувач не розуміє, що не так. Краще дозволити відправку й показати помилки;
  • правильні типи полів (type="email", inputMode="numeric", autoComplete="email") - правильна клавіатура на телефоні й автозаповнення;
  • стан відправки оголошувати текстом («Зберігаємо...»), а не лише спінером.

Перевірка: пройти форму лише клавіатурою (Tab, Enter, пробіл) і з увімкненим VoiceOver/NVDA.

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

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

Оптимістичне оновлення - показати результат дії одразу, не чекаючи відповіді сервера: повідомлення з'являється в чаті миттєво, лайк зараховується одразу. Якщо сервер відмовив - повернути як було.

useOptimistic(value, reducer?) (React 19) повертає тимчасовий стан, що діє, поки виконується дія:

import { useOptimistic, useState } from 'react';

function Comments({ initialComments }) {
  const [comments, setComments] = useState(initialComments);
  const [optimisticComments, addOptimistic] = useOptimistic(
    comments,
    (current, text) => [...current, { id: `temp-${Date.now()}`, text, sending: true }],
  );

  async function addComment(formData) {
    const text = formData.get('text');
    addOptimistic(text);                         // одразу на екрані
    try {
      const saved = await api.createComment(text);
      setComments((list) => [...list, saved]);   // справжні дані
    } catch {
      toast.error('Коментар не надіслано');      // оптимістичний стан зникне сам
    }
  }

  return (
    <>
      <ul>
        {optimisticComments.map((c) => (
          <li key={c.id} style={{ opacity: c.sending ? 0.6 : 1 }}>{c.text}</li>
        ))}
      </ul>
      <form action={addComment}>
        <input name="text" />
      </form>
    </>
  );
}

Як це працює:

  • поки дія виконується, optimisticComments = результат reducer (чи значення, передане в сеттер);
  • коли дія завершилася, React повертає optimisticComments до value - тобто до comments;
  • успіх: comments уже містить збережений коментар - користувач бачить справжні дані;
  • помилка: comments не змінено - тимчасовий коментар просто зникає. Ручного «відкату» не треба.

Умови:

  • сеттер оптимістичного стану треба викликати всередині дії (<form action>, startTransition). Поза нею React попередить, і оптимістичне значення лише мигне;
  • reducer має бути чистим.

Що враховувати в UX:

  • позначати непідтверджені дані (напівпрозорість, «надсилається»), щоб користувач розумів, що вони ще не збережені;
  • повідомляти про невдачу - тихе зникнення коментаря гірше за помилку;
  • не застосовувати для незворотних дій (оплата, видалення без кошика): оптимістичний «успіх» там вводить в оману;
  • тимчасові ключі - згенерований id, щоб React не переплутав оптимістичний елемент зі справжнім.

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

Подвійний клік, Enter у полі під час повільного запиту, повторна відправка після «зависання» - типові джерела дублікатів замовлень і коментарів. Захист потрібен на кількох рівнях.

1. Інтерфейс - не дати відправити вдруге:

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Оформлюємо...' : 'Оформити'}</button>;
}

З useActionState дії ставляться в чергу й виконуються по черзі, а не паралельно, - результат передбачуваний навіть при повторних натисканнях. Але вони все одно виконаються всі.

2. Своя логіка відправки - прапорець у ref, а не лише стан:

const submitting = useRef(false);

async function handleSubmit(values) {
  if (submitting.current) return;
  submitting.current = true;
  try {
    await api.placeOrder(values);
  } finally {
    submitting.current = false;
  }
}

Стан (useState) оновлюється з рендером - два швидкі кліки можуть обидва побачити false. Ref змінюється одразу.

3. Гонитва відповідей - коли новий запит починається до завершення попереднього (пошук, фільтри, автозбереження). Стара відповідь може прийти пізніше й перезаписати нову:

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setResults)
    .catch((e) => { if (e.name !== 'AbortError') throw e; });
  return () => controller.abort();   // скасувати попередній запит
}, [query]);

Або бібліотеки даних (TanStack Query), що розв'язують це самі.

4. Сервер - останній і головний рубіж. Клієнтський захист обходиться повторним запитом, повільною мережею з повтором, двома вкладками:

  • ключ ідемпотентності: клієнт генерує crypto.randomUUID() при відкритті форми й надсилає його з запитом; сервер не створює вдруге замовлення з тим самим ключем;
  • унікальні обмеження в базі (одна підписка на email);
  • блокування (Cache::lock у Laravel) для операцій, що не повинні виконуватися паралельно.

5. Після успіху - перенаправлення чи скидання форми, щоб «Назад» і повторне натискання не відправили ті самі дані знову.

Докладніше в документації: form: стан очікування

Файлове поле завжди неконтрольоване: задати йому значення з коду не можна, тож value не передають. Файли читають з події чи з FormData.

function AvatarForm() {
  const [preview, setPreview] = useState(null);

  useEffect(() => () => preview && URL.revokeObjectURL(preview), [preview]);

  function onChange(e) {
    const file = e.target.files?.[0];
    if (!file) return;
    if (file.size > 2 * 1024 * 1024) {
      alert('Файл більший за 2 МБ');
      e.target.value = '';                    // скинути вибір
      return;
    }
    setPreview(URL.createObjectURL(file));
  }

  async function upload(formData) {
    await fetch('/api/avatar', { method: 'POST', body: formData, headers: { Accept: 'application/json' } });
  }

  return (
    <form action={upload}>
      <input type="file" name="avatar" accept="image/png,image/jpeg" onChange={onChange} />
      {preview && <img src={preview} alt="Попередній перегляд" width={120} />}
      <button>Завантажити</button>
    </form>
  );
}

Що тут важливо:

  • URL.createObjectURL створює посилання на файл у пам'яті браузера. Його треба звільняти (revokeObjectURL) при заміні файлу й знищенні компонента, інакше пам'ять не звільняється до закриття вкладки;
  • Content-Type не задавати вручну для FormData - браузер сам поставить multipart/form-data з межею (boundary);
  • accept і перевірка розміру - лише зручність; сервер перевіряє тип за вмістом (image, mimes) і розмір заново;
  • multiple - e.target.files дає список; у FormData - formData.getAll('photos').

Прогрес завантаження. fetch не повідомляє про прогрес відправки тіла. Для смуги прогресу - XMLHttpRequest (xhr.upload.onprogress) чи бібліотека, що його використовує.

Великі файли (відео, архіви):

  • завантаження частинами з продовженням після обриву;
  • пряме завантаження в S3 за підписаним URL, отриманим від Laravel (Storage::temporaryUploadUrl()): файл іде з браузера в сховище, минаючи сервер застосунку;
  • обмеження сервера (upload_max_filesize, client_max_body_size у Nginx) мають відповідати очікуваним розмірам.

PUT/PATCH з файлами в Laravel: PHP історично розбирає multipart лише для POST - тому відправляють POST з полем _method=PUT.

Доступність і UX: кнопка вибору файлу має мітку, а для перетягування (drag and drop) лишається звичайне поле як запасний варіант.

Докладніше в документації: input: читання значень при відправці

У React 19 є три підходи до форм, і вони добре поєднуються.

1. Нативна форма + дії (action, useActionState, useFormStatus)

const [state, formAction, isPending] = useActionState(saveSettings, initial);
<form action={formAction}>...</form>
  • мінімум коду: значення читаються з FormData, без стану на кожне поле;
  • вбудовані стан очікування, черга дій, оптимістичні оновлення;
  • прогресивне покращення з Server Functions (Next.js та інші RSC-фреймворки): форма відправляється навіть до завантаження JavaScript, а з permalink у useActionState браузер знає, куди перейти;
  • слабкі місця: валідація на клієнті під час введення, залежні поля, динамічні списки - доводиться дописувати вручну.

Добре для: форм налаштувань, підписки, пошуку, коментарів - коротких форм, де головне - відправка й показ результату.

2. Контрольований стан (useState, useReducer)

  • повний контроль над кожним символом: форматування, обчислювані поля, умовна логіка;
  • кожне введення - рендер компонента з формою. На великих формах помітно без мемоізації й розбиття на компоненти.

Добре для: невеликих інтерактивних форм з тісною взаємодією полів (калькулятори, конфігуратори).

3. Бібліотека (React Hook Form + Zod, TanStack Form)

  • валідація за схемою зі спільним типом, режими перевірки (onBlur, onChange), фокус на помилці;
  • масиви полів (useFieldArray), майстри з кроками, «брудні» поля;
  • продуктивність великих форм - мінімум рендерів.

Добре для: великих бізнес-форм, адмінок, форм з динамічними частинами.

Як поєднувати:

  • бібліотека для стану й валідації, а відправка - у дію (startTransition усередині onSubmit) - щоб отримати isPending і оптимістичні оновлення;
  • нативна форма з useActionState, а для одного складного поля - контрольований компонент.

Що не залежить від підходу:

  • сервер перевіряє все - клієнтська валідація лише прискорює зворотний зв'язок;
  • серверні помилки мають потрапити до полів (422 від Laravel чи errors від Inertia);
  • доступність (мітки, aria-invalid, фокус) і захист від подвійної відправки;
  • узгодженість у проєкті: один основний підхід, а не три різні в сусідніх формах.

З Inertia вибір часто вже зроблено: useForm чи компонент <Form> з Inertia дають стан, помилки й відправку з урахуванням маршрутизації Laravel.

Докладніше в документації: form: відправка через Server Function